A screen at a time, not all at once. Start with a read-only screen: it is the smallest piece
of surface and it exercises almost everything — availability, session lifecycle, a tag, and the
NDEF codec.
NFCTagReaderSession on iOS, and
only one session can be open at a time — so as long as you are not scanning from
two screens simultaneously, a partial migration works.
The shape of the change
nfc-manager gives you a module you drive through a sequence of calls, and you own
the session:
The equivalence table
Six differences that will change your code
1. Bytes areUint8Array, not arrays of numbers. A 1 KB APDU response was 1024
boxed JavaScript numbers; now it is 1024 bytes. Where you had
[0x00, 0xa4, 0x04, 0x00], write new Uint8Array([0x00, 0xa4, 0x04, 0x00]) — or
better, use the ISO 7816 helpers and stop hand-assembling APDUs.
2. transceive returns the same shape on both platforms. nfc-manager returned
[...bytes, sw1, sw2] on iOS and raw bytes on Android, with a // TODO in its own
source admitting it. If you have platform branches around a transceive, delete
them. For APDUs, transceiveApdu splits the status word off for you:
sendApdu from /protocols does the same and adds 61xx/6Cxx handling. It takes
a transport — a function from bytes to bytes — rather than the tag itself, which
is what makes it testable without hardware:
isoDepHandler you can reach for on a tag that is not ISO-DEP. tag.is('isoDep')
is what makes transceive exist, in the type system and at runtime. On iOS
tag.is('mifareClassic') is always false, because CoreNFC cannot reach Crypto-1
at any version — so a branch you wrote for Android is simply not entered rather
than throwing somewhere unexpected.
4. Every rejection is an NfcError with a code. nfc-manager rejected with
bare strings from some paths, with Error subclasses that had no message and no
code from others, and its Android side recognised exactly one error string. Replace
string comparisons:
userCancelled, sessionTimeout,
tagLost, nfcDisabled and systemBusy. Everything else is a bug or a tag
problem, and the message says which.
5. Cancelling is not an error, and aborting is not cancelling. nfc-manager
translated a user cancellation into “closed with no error”, so you could not tell
the two apart. Here userCancelled means the user dismissed the sheet and
aborted means your own AbortSignal fired.
6. Subscriptions are real. setEventListener kept one callback per event name,
so a second registration silently replaced the first and there was no way to
unsubscribe. nfc.onTag(...) returns a subscription with .remove(), and several
listeners can coexist.
Things that quietly get better
You do not have to do anything for these; they are listed so you know what to stop working around.- No 1-second delay on Android.
cancelTechnologyRequestslept 1000 ms as a race workaround, so cancelling took a second on Android and no time on iOS. - The session cannot leak. A native method that never called its callback left a promise pending forever, and one that called it twice crashed under the New Architecture. Both were shipped bugs. On iOS every continuation here goes through a type that can only be resumed once.
- Tag I/O is off the main thread.
nfc-managercreated both iOS sessions ondispatch_get_main_queue(), so radio I/O blocked the UI, and ran blocking Android I/O on the React Native module thread while holding a module-wide monitor. Here iOS uses a dedicated serial queue and Android a dedicated single-thread dispatcher. - No chipset guessing. MIFARE Classic support is read from the tag’s own
technology list rather than by probing
/dev/bcm2079x-i2c, scanning/system/lib, and special-casing one Lenovo model by name. - “Do I need to rebuild?” is answered at runtime. A JavaScript bundle newer
than the installed native binary fails with
contractMismatchand a message saying to rebuild the development client, instead of anundefined is not a function.
What is not here yet
Being explicit so you can check before you commit to a migration:- MIFARE Classic sector helpers.
mifareClassicHandlerAndroidhadauthenticateSectorWithKeyA,readBlockand friends. Here MIFARE Classic is reachable as rawtransceiveon Android, but the sector authentication helpers are not wrapped yet. If you rely on them, wait or open an issue. - Type 3 tag emulation (
HostNfcFService). Card emulation here isHostApduService, which is ISO 7816. FeliCa emulation is not implemented. - Anything on iOS that needs an entitlement Apple grants case by case, which is card emulation and Wallet passes. Both are implemented; both need Apple to have said yes to you.
Installing them side by side
NfcManager call is gone, remove it — and its config plugin, which
asks for both iOS reader-session formats unconditionally and cannot be told not to.
The plugin here asks for the one format the implementation actually uses.
A worked example
A check-in screen, before:finally, and the
NfcManager.start() are all gone. presenceCheckDelayMs is new and worth setting
for anything doing crypto: Android’s default is 125 ms, which DESFire
authentication routinely exceeds, and the OS then declares the tag lost part-way
through an exchange that was going fine.
Next
Sessions
withTag, openSession and onTag in full.Error reference
Every code, replacing the string comparisons you are deleting.