@voova/capacitor-nfc-adapter
v0.4.0
Published
Dual-platform NFC card UID scanner (Android USB Host/CCID readers + iOS wired CCID readers via CryptoTokenKit, with CoreNFC fallback) delivering the strict uid_dec_reversed format
Maintainers
Readme
:nfc-adapter — Dual-Platform NFC Scanning Module
Delivers student-card UIDs to the host app through one unified callback on both platforms:
| Platform | Engine | Hardware |
|---|---|---|
| Android | USB Host API (CCID class 11 / serial bridges / Brand A SDK hook) | External USB Type-C readers, plug & play |
| iOS | CryptoTokenKit CCID slot (TKSmartCardSlotManager + PC/SC FF CA 00 00 00) | External wired reader (ACR1255U-J1 through a Lightning/USB adapter), plug & play |
| iOS (fallback) | CoreNFC NFCTagReaderSession (ISO 14443) | Built-in antenna — card taps the iPhone |
The output contract — uid_dec_reversed
Every engine on every platform delivers the decimal representation of the reversed raw UID byte array, no padding:
raw bytes read from card : FB 5D 82 29
reversed : 29 82 5D FB
onCardScanned() receives : "696409595"Pinned by shared-test-vectors/uid_vectors.json (values computed with an
independent .NET BigInteger oracle) across three test lanes: Kotlin/JVM
(src/test), Swift/XCTest (ios/Tests) and the toolchain-free Node harness
(archive/verify.mjs, 69 checks — run with node archive/verify.mjs).
Layout
src/main/kotlin/com/school/nfcadapter/
api/ NfcScannerPort + NfcScanListener (the ONLY public surface)
core/ UidNormalizer, DeviceRouter, ConnectionManager (generation
tokens, attach debounce, storm/brownout detection), ListenerProxy
usb/ attach trampoline, detach receiver, permission coordinator
(API 31+ FLAG_MUTABLE + API 34 explicit-intent compliant)
transport/ UsbTransport seam + AndroidUsbTransport (bulkTransfer)
handler/ccid/ pure CCID protocol + edge-triggered polling engine
handler/serial/ pure frame assembler/profiles + usb-serial-for-android wrapper
handler/branda/ Brand A SDK hook (integration contract in the file header)
ios/Sources/ Swift twin: NfcScannerPort, UidNormalizer, CoreNfcScannerPort
ios/Tests/ XCTest suites (same golden vectors)Capacitor usage (@voova/capacitor-nfc-adapter)
The package is an autolinkable Capacitor 3 plugin: android/ (Gradle library +
@CapacitorPlugin NfcAdapterPlugin), ios/Plugin/ (Swift CAPPlugin bridge
registered via the CAP_PLUGIN macro, CapacitorNfcAdapter.podspec at package
root), index.js entry point using registerPlugin('NfcAdapter') with a no-op
web fallback for ionic serve.
import { NfcAdapter } from '@voova/capacitor-nfc-adapter';
const handle = NfcAdapter.addListener('onCardScanned', ({ uid }) => checkIn(uid)); // "696409595"
await NfcAdapter.startScanning();
// later: await NfcAdapter.stopScanning(); handle.remove();Install in the host app, then npx cap sync to wire the native projects
(capacitor.settings.gradle on Android, Podfile on iOS/macOS machines).
Android integration (native, non-RN)
val scanner: NfcScannerPort = NfcAdapterModule.create(context) // Noop on non-OTG devices
scanner.setListener(object : NfcScanListener {
override fun onCardScanned(uidDecReversed: String) { checkInGateway.submit(USB_NFC, uidDecReversed) }
override fun onReaderStateChanged(state: ReaderState) { /* standby badge */ }
override fun onReaderError(error: ReaderError) { /* driver-facing hint */ }
})
scanner.start() // route screen enter / login
scanner.stop() // route screen exit / logoutGradle: add maven { url = uri("https://jitpack.io") } to repositories
(for usb-serial-for-android). The library manifest already declares
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
— verify it survives manifest merge with aapt dump badging on the release APK.
iOS integration
Minimum iOS version: 16.0. The production route is the external wired reader, and iOS only publishes the reader's CCID slot to third-party apps from iOS 16 — so the pod's floor is 16.0 rather than a lower number the wired route could not honour.
Transport selection is automatic — the factory prefers a wired reader and only falls back to the built-in antenna when the platform publishes no CCID slot service:
let scanner = IosScannerPortFactory.make() // .auto (default)
let scanner = IosScannerPortFactory.make(route: .wired) // force wired CCID
let scanner = IosScannerPortFactory.make(route: .coreNfc) // force built-in antenna
scanner.setListener(listener)
scanner.start()
scanner.stop()From the Capacitor side the route is set with the iosScannerRoute plugin
config value ("wired" | "coreNfc" | "auto", default "auto").
Wired route (external ACR1255U-J1) — the production path
CcidWiredScannerPort talks to the reader through the system CCID stack:
- iOS exposes no general-purpose USB-host API, and this reader's cable
interface is not an MFi/iAP accessory —
EAAccessoryManager.connectedAccessoriesis empty with the reader attached (verified at runtime). - The system's CCID stack does enumerate it, and
TKSmartCardSlotManagerpublishes that slot to third-party apps with no entitlement from iOS 16 onwards. Verified on iOS 16.7.12:slotNames == ["ACS ACR1255U-J1"], one card presentation returns SW9000with a 4-byte UID. - No
NFCReaderUsageDescriptionand no NFC entitlement are required for this route — those apply only to the CoreNFC fallback. - Requires iOS 16+ for the slot service, which is exactly the pod's deployment target — no supported OS version is left without a working wired route.
Semantics match Android: health-gated Ready (two consecutive successful
non-destructive probes — never a false Ready), deterministic DISCONNECTED on
detach with automatic re-arm on reconnect, and exactly one onCardScanned per
card presentation (the field must go free before the next tap counts).
CoreNFC fallback
Required target config for the fallback only: see ios/INFO_PLIST_ADDITIONS.md
(NFCReaderUsageDescription + Tag Reading entitlement).
Honest platform limits (by Apple design): the CoreNFC route shows the system
scan sheet whenever scanning is active — there is no silent background standby,
and sessions are capped at ~60 s (the port auto-restarts them while start() is
in effect). The wired route has neither limitation: no sheet, no session cap.
Testing without hardware
archive/verify.mjs— runs anywhere Node exists; exercises normalizers, CCID validation chain, engine state machines, serial assembly, router, and the vibration-storm/generation-token model.- Kotlin suite — pure classes have zero Android imports; runs as plain JVM
unit tests in CI (
ScriptedUsbTransportscripts taps/flicks/disconnects). - Swift suite —
CoreNfcScannerPort.handleDiscoveredIdentifier(_:)is the pure seam; tests inject mockDataidentifiers, valid and corrupted.
Open items before field rollout
- Brand A
.aar: follow the contract inhandler/branda/BrandAHandlerHook.kt; setbrandAVendorId/brandAProductIdsinNfcModuleConfigand add the VID/PID tores/xml/nfc_device_filter.xml. - Confirm with the roster/backend owner that stored card numbers match
uid_dec_reversed(this format was chosen to match keyboard-emulator output; verify endianness against a sample of real production cards). - Tier-4 on-device smoke: one CCID reader + one CH340 reader on the two most common fleet phones (cold plug, hot plug, "Always open" permission path, 100-tap soak, mid-tap unplug).
