No Flutter NFC Package Could Do Both, So I Built One

15 min read

Contents
  1. What is out there
  2. The dream API
  3. The layers
  4. The phone’s antenna
  5. The USB reader
  6. Speaking CCID
  7. Polling for cards
  8. Talking to the chip through the reader
  9. Telling cards apart
  10. The reliability release
  11. “Sometimes it cannot read”: a promise nobody kept
  12. “Sometimes it reads twice”: two different doubles
  13. “Sometimes info is missing”: the probe in the middle
  14. A callback that could kill the session
  15. Testing a hardware library without the hardware
  16. What’s next
  17. What I took from it

It started with a simple-sounding requirement. Read an NFC card in a Flutter app on Android. Sometimes the card is tapped on the device’s own antenna. Sometimes it sits on a USB reader plugged into the device, because the device has no antenna, or because a reader on a cable is where people actually put their card.

To a person, it is the same card. To the code, it is two completely different conversations.

The built-in antenna speaks Android’s NFC API: NfcAdapter, reader mode, Tag objects, NfcA, IsoDep, MifareClassic. Android discovers the card for you and tells you what it is. The USB reader speaks CCID, a smart-card protocol over USB bulk transfers, wrapped around PC/SC APDUs, wrapped around commands for the NFC chip inside the reader. Nobody tells you a card arrived. Nobody tells you what kind of card it is. You get an ATR, a string of bytes, and you work it out.

I assumed someone had already solved this. So I went shopping.

What is out there

Here is the Flutter NFC landscape as pub.dev shows it today (October 2026):

PackageLast releaseWhat it coversPub points
nfc_managerApril 2026Built-in NFC on Android and iOS. Raw platform tag handles; NDEF lives in a separate package160
flutter_nfc_kitOctober 2026Built-in NFC on Android and iOS, with tag metadata, NDEF and transceive. Web works only with special dual-interface USB devices160
ccidAugust 2026USB smart-card readers with a PC/SC-style API: connect, send APDU, get bytes back160
flutter_pcscOctober 2021PC/SC readers on desktop only. No Android140
nfc_in_flutterJuly 2020Built-in NDEF only. Not Dart 3 compatible40
flutter_nfc_readerOctober 2019Built-in NDEF reading. Not Dart 3 compatible40

The two big ones are good. nfc_manager and flutter_nfc_kit are actively maintained, together see around 200,000 downloads a month, and if all you need is a phone tapping a sticker, use them.

But read the table again with my requirement in mind:

  • Every package that talks to the built-in antenna ignores USB readers. Not one of them can read a card seated on an ACR122U.
  • The maintained package I found for USB readers on Android, ccid, is a pipe. You connect, you send bytes, you get bytes. It does not notice a card arriving or leaving, does not tell you whether it is MIFARE or FeliCa or DESFire, and does not know the reader-specific commands you need to find out.
  • The long tail is abandoned. Several packages that still show up in search have not had a release in five to seven years and no longer work with current Dart.

So “read the card, wherever it is, and tell me what it is” meant two libraries with two completely different APIs, plus all the card identification written by hand on the USB side, plus glue code in the app to make them look alike. Every app that needs this would write the same glue, and get the same subtle bugs.

I did not want glue. I wanted one smart reader. So I forked ccid, kept its USB transport, and built the rest: built-in NFC, automatic card detection, card identification and one API over both. It became nfc_hub. It is open source and not on pub.dev yet. This is how it works, and the bugs that taught me the most.

The dream API

This is what I wanted app code to look like:

NfcHubReader.instance.startSession(
  onDiscovered: (tag) async {
    final info = await tag.readTagInfo();
    print('${info.technology}, UID ${info.uid}');
    if (tag is Type2Tag) print(await tag.readNdef());
  },
  onError: (error) {
    // an unreadable tap, or a card pulled away mid-read
  },
);

await NfcHubReader.instance.stopSession();

One callback. Tap a card on the phone or seat it on the reader, onDiscovered fires once, with a driver for that card family: Type 2, MIFARE Classic, FeliCa, ISO-DEP (including DESFire), or Type B. The same readNdef() or readSector() works whichever way the card arrived.

Getting there meant agreeing on a contract first, and the contract turned out to matter more than any of the protocol code:

  • Exactly once. Each presentation of a card is delivered once. Not again on a widget rebuild, not again when the session restarts.
  • Serialized. If onDiscovered returns a future, the next tag waits for it. Reads on one card never overlap the next.
  • The session survives. A bad tap, a lost tag or a throwing callback goes to onError. The session keeps going.

The layers

                  app code
                     │
         NfcHubReader  (session, serialized callbacks)
                     │
         NfcHub        (one merged stream per filter)
           ┌─────────┴──────────┐
   AndroidNfcSource         CcidSource
   reader mode, debounce    500 ms poll loop, liveness probe
           │                    │
   AndroidTransportBackend  CcidTransportBackend (+ PN532 frames)
           └─────────┬──────────┘
        same 5 transport interfaces
   Type2 · MifareClassic · NfcF · IsoDep · NfcB
                     │
        tag drivers: Type2Tag, MifareClassicTag,
        FelicaTag, IsoDepTag (DESFire), TypeBTag

The trick is in the middle. Each source has a backend that implements the same five transport interfaces. A driver like FelicaTag only talks to an NfcFTransport. On the phone, that transport prepends a length byte and calls NfcF.transceive. On the USB reader, it prepends the same length byte, wraps the frame in a PN532 InDataExchange command (D4 40 01 ...), sends it through the reader’s escape APDU and checks for D5 41 00 in the reply. The driver never knows.

Not every interface is raw bytes. Type 2 and MIFARE Classic are method-based (readPage, authenticate, readBlock), because the USB reader only exposes those operations as reader-translated commands, and Android’s MIFARE authentication is not a byte stream you can proxy. I tried to force everything into raw frames first. It does not fit, and pretending it does just moves the mess into the drivers.

The phone’s antenna

Android gives you reader mode, which is the right tool for an app that is actively reading cards:

fun startReaderMode() {
    val a = activity ?: return
    val flags = NfcAdapter.FLAG_READER_NFC_A or
        NfcAdapter.FLAG_READER_NFC_B or
        NfcAdapter.FLAG_READER_NFC_F or
        NfcAdapter.FLAG_READER_NFC_V or
        NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK or
        NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS
    adapter?.enableReaderMode(a, this, flags, null)
}

When a tag is discovered, the Kotlin side pulls out its identity right away (UID, ATQA, SAK, ATQB, IDm, PMm, historical bytes) without connecting. Android already tells you which technologies the tag supports, so the Dart side turns the tech list into a hint and builds the right driver directly, with no probing:

TagTechHint? hintFromTechList(List<String> techList) {
  bool has(String cls) => techList.contains(cls);

  if (has('android.nfc.tech.MifareClassic')) return TagTechHint.mifareClassic;
  if (has('android.nfc.tech.NfcF')) return TagTechHint.felica;
  if (has('android.nfc.tech.IsoDep')) return TagTechHint.isoDep;
  if (has('android.nfc.tech.NfcB')) return TagTechHint.typeB;
  if (has('android.nfc.tech.NfcA')) return TagTechHint.type2;
  return null;
}

The order matters. A MIFARE Classic card also reports NfcA. Check NfcA first and every MIFARE card becomes a Type 2 tag.

The USB reader

The external side is where most of the work went. I tested against an ACR122U, the most common cheap USB NFC reader, which has an NXP PN532 chip inside.

Speaking CCID

CCID is the USB class for smart-card readers (interface class 0x0B). You find the bulk IN and OUT endpoints, parse the CCID class descriptor (type 0x21) for supported protocols and exchange level, claim the interface and power the card on. Power-on returns the card’s ATR, its Answer To Reset.

After that, every command is a 10-byte header followed by the APDU:

fun xfrBlock(apdu: ByteArray): ByteArray {
    val seq = currentSeq++
    val command = byteArrayOf(
        MESSAGE_TYPE_PC_TO_RDR_XFRBLOCK,       // 0x6F
        apdu.size.toByte(),
        (apdu.size shr 8).toByte(),
        (apdu.size shr 16).toByte(),
        (apdu.size shr 24).toByte(),           // dwLength, little-endian
        0x00,                                  // slot
        seq,                                   // matched against the reply
        0x00, 0x00, 0x00
    )
    val data = command + apdu

    var bytesSent = 0
    while (bytesSent < data.size) {
        val chunkSize = minOf(data.size - bytesSent, bulkOut.maxPacketSize)
        sendCcidPcToRdrMessage(data.copyOfRange(bytesSent, bytesSent + chunkSize))
        bytesSent += chunkSize
    }
    return receiveCcidRdrToPcMessage(seq).data
}

The reply carries the same sequence number and a status byte. The low two bits say whether a card is present, the top two say whether the command worked, and one value means “I need more time”, so the receive loop keeps waiting.

USB I/O is blocking, with timeouts of up to five seconds. Flutter method-channel calls arrive on the main thread. So every CCID call runs on one dedicated single-thread executor, and results are posted back. Single-threaded on purpose: the sequence counter must only ever be touched by one thread.

Polling for cards

A USB reader does not call you when a card arrives. So CcidSource polls every 500 ms while anyone is listening. Empty readers get a connect attempt. Occupied readers get a liveness probe: the PC/SC Get UID command, FF CA 00 00 00. If it fails, the card is gone. If it returns a different UID than the one recorded at connect, someone swapped the card.

Talking to the chip through the reader

Standard PC/SC only gets you so far. For SAK and ATQA, or for FeliCa and Type B frames, you need the PN532 itself. The ACR122U has an escape APDU, FF 00 00 00 <Lc> <PN532 frame>, that passes a frame straight to the chip:

Future<Uint8List> transceiveEscape(CcidCard card, Uint8List payload) {
  final lcHex = payload.length.toRadixString(16).padLeft(2, '0');
  final dataHex = hex.encode(payload);
  final capdu = 'FF000000$lcHex$dataHex'.toUpperCase();
  return transceiveChecked(card, capdu);
}

D4 4A 01 00 lists a Type A target and returns its ATQA, SAK and UID. D4 4A 01 03 does the same for Type B. This is vendor-specific, so everything that depends on it degrades gracefully on other readers instead of throwing.

Telling cards apart

On the phone, Android tells you the technology. On a USB reader you only get an ATR, and you have to work it out.

For contactless cards, PC/SC Part 3 defines a pseudo-ATR. Inside the historical bytes, after the registered ID A0 00 00 03 06, two bytes name the card: 00 01 is MIFARE Classic 1K, 00 03 is a Type 2 tag (Ultralight, NTAG), F0 11 is FeliCa. When that pattern is missing, the code probes, in order: read the Type 2 capability container, ask the PN532 for the SAK, send an ISO 7816 SELECT, try FeliCa polling, try Type B.

That probe order took three bug fixes to get right, all with the same symptom: a MIFARE Classic card was reported as an ISO-DEP card with an empty UID.

  1. When the PN532 poll failed, detection fell through to SELECT. The old check accepted any reply four characters or longer as “ISO-DEP answered”. The reply was 6D00, an error. MIFARE became ISO-DEP.
  2. The FeliCa fallback used Get UID, which every card answers. MIFARE cards with non-default keys became FeliCa.
  3. The final fix moved the SAK check before SELECT, retried the PN532 poll once, and made SELECT strict about status words:
bool _isIsoDepSw(String responseHex) {
  if (responseHex.length < 4) return false;
  final sw = responseHex.substring(responseHex.length - 4).toUpperCase();
  return sw == '9000' ||
      sw.startsWith('61') ||
      sw.startsWith('6C') ||
      sw == '6A82' ||
      sw == '6A86' ||
      sw == '6D00' ||
      sw == '6E00';
}

Wait, 6D00 is in that list? Yes. It means “instruction not supported”, which is a valid ISO-DEP answer from a real ISO-DEP card. The reason MIFARE stopped being misidentified is that SAK now decides first. The ACR122U answers 6300 for a card it could not talk to at all, and that is what the list rejects.

The reliability release

By version 0.6 everything worked on my desk. Then it met real use, and three complaints came back:

  • Sometimes it cannot read. On the external reader, and on the phone.
  • Sometimes it reads the same card twice.
  • Sometimes some of the card info is missing.

None of them reproduced on demand. That is the signature of timing and state bugs. Here are the root causes, because each one is a lesson that applies far outside NFC.

“Sometimes it cannot read”: a promise nobody kept

When the app first sees a USB reader, Android shows a permission dialog. The plugin stored the Flutter Result and waited for the broadcast. If the user denied the dialog, ignored it, or unplugged the reader, the Result was never completed. The Dart await hung forever. The poll loop has a guard so ticks never overlap, so it waited on that await forever too. The whole external reader was dead until the app restarted.

The fix was boring: every path completes the Result, with explicit error codes for denied, pending and detached. A denied reader is not asked again until it is re-attached.

Then I found the opposite bug. On an empty reader, the success path left an already-answered Result in the map. Unplug the reader and the detach handler answered it again, which crashes the app: IllegalStateException: Reply already submitted. Worse, while it sat there, every new connect() saw a stored Result, assumed a permission request was pending, and answered “pending” forever. The fix is a take-and-clear:

private fun takePendingResult(name: String): Result? {
    val reader = readers[name] ?: return null
    val pending = reader.result ?: return null
    readers[name] = reader.copy(result = null)
    return pending
}

It is atomic by thread confinement, not by a lock: the broadcast receivers and the main handler all run on the main thread.

Two other “cannot read” causes:

  • A phantom card. The ACR122U sometimes answers power-on with success and a stub ATR, 3B 00, when no card is there. Every later command fails with “card not present”, the poll loop disconnects, and 500 ms later connects to the phantom again. No real contactless card has an ATR shorter than five bytes, so anything shorter is now treated as “no card”.
  • A connection leak. Failed connects on an empty reader leaked a USB connection and a claimed interface, twice a second. Eventually the reader could not power on a real card. Every failure path now closes the connection in a finally.

On the phone side, reader mode silently does nothing if you enable it while NFC is switched off. Turn NFC on mid-session and the antenna stayed dead. The plugin now re-arms reader mode when the adapter state changes to on.

“Sometimes it reads twice”: two different doubles

On the USB side, a card seated while nothing was listening for tags was held and replayed to the next subscriber, which was correct. But it was replayed to every new subscriber, so every widget rebuild or session restart read the card again. Now each held driver has a delivered flag, and only undelivered cards are replayed.

On the phone, reader mode can re-enumerate the same physical tag when the field flickers, and call onTagDiscovered again. To the app that is one tap, read twice. Same UID within 1,000 ms is now treated as the same tap. The window slides, so a card held still against the phone is never re-delivered, and stopping the session resets it, so a deliberate re-tap after a restart still counts.

“Sometimes info is missing”: the probe in the middle

DESFire’s GetVersion is three frames: send 60, get hardware info and AF (“more”), send AF, get software info, send AF again, get UID and production date. The liveness probe runs every 500 ms. If it landed between two of those frames, the card dropped the chained command and the version info came back empty.

Two fixes. First, every card gets a serial chain, so an APDU never starts until the previous one finished:

Future<void> _chain = Future<void>.value();

Future<String?> transceive(String capdu) {
  final completer = Completer<String?>();
  _chain = _chain.then((_) async {
    try {
      completer.complete(
          await NfcHubPlatform.instance.transceive(reader, capdu));
    } catch (e, st) {
      completer.completeError(e, st);
    } finally {
      lastActivity = now();
    }
  });
  return completer.future;
}

Second, the probe skips itself if the app talked to the card within the last poll interval. Recent traffic already proves the card is there, and you don’t have to inject anything into a conversation that is going fine.

The phone had its own version of “missing info”. Tag I/O ran on the main thread. A slow read let the platform’s presence check decide the tag was gone, mid-read. Moving built-in NFC I/O to its own background executor fixed it, the same way the USB side had been fixed one release earlier.

A callback that could kill the session

One more, because it is so easy to write. onDiscovered calls are chained so they run one at a time. If onDiscovered threw, the error went to onError. But if onError itself threw, that chain entry failed, and every later onDiscovered in the session was silently skipped. The fix is one more try around the error report. It is three lines. Finding it took much longer.

Testing a hardware library without the hardware

There are 257 tests, and almost none of them need a card. The platform layer is an interface, so tests swap in hand-written fakes: a fake CCID platform that maps a command hex string to a response, a sequenced one for DESFire’s repeated AF, a slow one whose calls finish only when the test says so, a flaky one whose PN532 poll fails once.

Both sources take an injected clock, and fake_async drives the 500 ms poll loop and the 1,000 ms debounce without waiting. The tests that saved me are the ones named after bugs: the liveness probe is skipped while the app is reading, concurrent transceives never interleave, a late listener does not get a removed card’s driver.

What a fake cannot catch is a reader that answers power-on with 3B 00. For that there is a hardware checklist: deny the permission and re-attach, ten taps give exactly ten deliveries, unplug mid-read, leave an empty reader alone for a minute and watch for a phantom loop.

I built a lot of this with a coding agent beside me, and it was fast at the protocol plumbing. The bugs above are the ones that only showed up when real people tapped real cards. Finding them was reading logs, reproducing timing and asking what state was left behind. That part is still the job.

What’s next

nfc_hub is open source on GitHub and started as a fork of nfcim/ccid, whose CCID transport it still builds on. Version 0.8.0 is planned as the first pub.dev release: Android-only, clearer names (CcidSourceFilter becomes NfcSource), and CI that runs the full test suite. The pana score is 150 out of 160 today, and the missing ten points are formatting and lints.

After that, NFC-V (ISO 15693) tags. Reader mode already discovers them. There is just no driver yet.

What I took from it

  • Write the delivery contract before the protocol code. “Exactly once, serialized, survives errors” decided more of the design than CCID did.
  • Every platform call must complete, on every path. A promise nobody resolves does not crash. It just quietly stops everything behind it.
  • Never let a background check talk over a conversation. If you probe a shared resource, serialize access and skip the probe when the resource is obviously busy.
  • Hardware lies a little. A reader that says “success, here is a card” when there is no card needs a rule in code, not a retry.
  • Test with fakes, validate on hardware. The fakes make the logic safe to change. Only the hardware tells you what the logic has to handle.