NFC Util
NFC for Flutter on Android and iOS: reader sessions, every tag technology both platforms expose, a real NDEF layer, background tag reading, host card emulation, and Apple Wallet passes.
await NfcUtil.instance.startSession(
onDiscovered: (tag) async {
final message = await Ndef.from(tag)?.read();
for (final record in message?.records ?? const []) {
print(TextRecord.from(record)?.text);
}
await NfcUtil.instance.stopSession();
},
);
Contents
New here? Everything you need for a first tag read is in Quick start.
- What it does
- Install
- Quick start
- Setup in detail
- Android 16 and 17
- The six libraries
- Sessions
- NDEF
- Tag technologies
- ISO 7816 APDUs
- Background tag reading
- Host card emulation
- Apple Wallet passes
- Errors
- Testing
- Upgrading from 2.2.0
- Roadmap
- License
What it does
| Android | iOS | |
|---|---|---|
| Reader sessions | enableReaderMode |
NFCTagReaderSession |
| Several tags in one detection | — reader mode delivers one tag per callback | ✅ counted by NfcTag.otherTagCount |
| NDEF read / write / lock | ✅ | ✅ |
| NDEF format (unformatted tag) | ✅ | — CoreNFC has no equivalent |
| NDEF wire codec, typed records | ✅ pure Dart, works with no tag | ✅ |
| NfcA / NfcB / NfcF / NfcV / IsoDep | ✅ | — reachable as the CoreNFC protocols |
| Mifare Classic | ✅ auth, blocks, value ops, geometry | — Apple does not allow it |
| Mifare Ultralight | ✅ | ✅ via MiFare |
| FeliCa | ✅ via NfcF |
✅ 10 typed commands |
| ISO 15693 | ✅ via NfcV |
✅ 30 typed commands, the security ones included, plus raw sendRequest |
| ISO 7816 | ✅ via IsoDep |
✅ |
ISO 7816-4 APDUs, status words, 61xx chaining |
✅ pure Dart, works with no tag | ✅ |
| Tag connection reset | ✅ reset() |
— restarting polling is the nearest thing, and it gives the tag up |
| Barcode (Kovio) tags | ✅ | — |
| Background / launch-on-tag reading | ✅ intent filters | ✅ NDEF user activity |
| Host card emulation | ✅ runtime AID registration | — not available to third-party apps |
| Card-emulation readback: AID prefix support, registered AIDs, defaults | ✅ | — |
| Observe mode, polling loop filters | ✅ Android 15 (API 35) | — |
| Discovery technology, antenna geometry | ✅ API 35 / API 34 | — |
| Card-emulation event stream | ✅ Android 16 (API 36) | — |
| Tag-intent allowlist and dispatch check | ✅ Android 16 / 17 | — |
| Apple Value Added Services | — | ✅ Wallet passes |
| Adapter state stream, secure NFC | ✅ | — no such state on iOS |
| Reader-option probe: tag reading off while NFC is on | ✅ Android 15 (API 35) | — no such switch on iOS |
| Typed errors | ✅ 8 codes | ✅ 24 CoreNFC codes |
Install
dependencies:
nfc_util: ^3.3.0
Requires Flutter 3.44, Android API 24, iOS 15.6.
Quick start
Six steps from an empty project to a tag read on a real phone. Nothing else in this README is needed to get that far.
1. Add the package
flutter pub add nfc_util
Then check your project clears the floor: Flutter 3.44, Android API 24 (minSdk in
android/app/build.gradle.kts), iOS 15.6 (IPHONEOS_DEPLOYMENT_TARGET in Xcode).
2. Android: nothing to do
The plugin declares android.permission.NFC for you, and android.hardware.nfc as not
required, so an app that only reads tags needs no manifest change at all. Skip to step 4.
If your app cannot work without NFC, declare the feature as required in your own manifest:
<uses-feature android:name="android.hardware.nfc" android:required="true" />
No tools:replace is needed. The manifest merger ORs android:required across manifests, so
your true wins over the plugin's false on its own — and tools: would need an
xmlns:tools declaration the stock Flutter manifest does not carry.
Android needs manifest entries only for letting a tag launch your app while it is closed, which is a later concern — see Setup in detail.
3. iOS: three things
a. In Xcode, open ios/Runner.xcworkspace → select the Runner target → Signing &
Capabilities → + Capability → add Near Field Communication Tag Reading.
b. Open ios/Runner/Info.plist and add both keys below. The first is the sentence iOS
shows the user when the reader opens; the second is the one people forget:
<key>NFCReaderUsageDescription</key>
<string>This app uses NFC to read tags.</string>
<key>com.apple.developer.nfc.readersession.felica.systemcodes</key>
<array>
<string>12FC</string>
<string>8008</string>
<string>0003</string>
<string>FE00</string>
</array>
c. Know why b matters. startSession polls for every tag type by default, FeliCa
included, and CoreNFC refuses a FeliCa poll unless those system codes are listed. Without the
key no reader sheet appears, no exception is thrown, and it looks like your code did
nothing. If you would rather not poll FeliCa at all, leave the key out and pass
pollingOptions: {NfcPollingOption.iso14443} to startSession instead. This is the single
most common iOS mistake with this package.
4. Write your first screen
Replace lib/main.dart with this. It runs as it stands: press the button, hold a tag to the
phone, read its text.
import 'package:flutter/material.dart';
import 'package:nfc_util/ndef.dart';
import 'package:nfc_util/nfc_util.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: TagReaderPage());
}
class TagReaderPage extends StatefulWidget {
const TagReaderPage({super.key});
@override
State<TagReaderPage> createState() => _TagReaderPageState();
}
class _TagReaderPageState extends State<TagReaderPage> {
String _status = 'Press the button, then hold a tag against your phone.';
void _show(String message) {
if (mounted) setState(() => _status = message);
}
Future<void> _readTag() async {
// 1. Can we use NFC right now? This never throws, so it is safe as a gate.
final availability = await NfcUtil.instance.checkAvailability();
if (availability != NfcAvailability.enabled) {
_show(availability == NfcAvailability.disabled ? 'NFC is switched off in Settings.' : 'This phone has no NFC.');
return;
}
// 2. Start reading. On iOS this is what opens the system reader sheet.
await NfcUtil.instance.startSession(
alertMessageIos: 'Hold your phone near the tag',
onDiscovered: (tag) async {
// 3. A tag arrived. Read its NDEF message and pull out the text records.
final message = await Ndef.from(tag)?.read();
final texts = <String>[];
for (final record in message?.records ?? const <NdefRecord>[]) {
final text = TextRecord.from(record);
if (text != null) texts.add(text.text);
}
_show(texts.isEmpty ? 'Tag read, but it holds no text.' : texts.join('\n'));
// 4. Done with this tag: close the session.
await NfcUtil.instance.stopSession(alertMessageIos: 'Done');
},
onError: (error) async => _show(error.message),
);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Read an NFC tag')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_status, textAlign: TextAlign.center),
const SizedBox(height: 24),
FilledButton(onPressed: _readTag, child: const Text('Read a tag')),
],
),
),
);
}
5. Run it on a real phone
flutter run
It has to be a physical phone. No Android emulator and no iOS Simulator has an NFC radio, so there the button only reports that the device has no NFC. For a test tag, any cheap NTAG213 sticker works — write some text onto it with any NFC writer app first, so there is something to read.
6. What you should see
The two platforms feel different, which is normal and not a bug:
| Android | iOS | |
|---|---|---|
| After pressing the button | nothing visible — the phone is already listening | the system reader sheet slides up, showing your alertMessageIos text |
| When the tag touches | the system tag sound, then the text on screen | the sheet reports "Done" and dismisses itself, then the text |
| If no tag ever arrives | the session stays open until you stop it | iOS closes the session on its own and onError fires |
That is the whole loop. From here:
- reading or writing more than plain text → NDEF
- Mifare, FeliCa, ISO 15693, ISO 7816 → Tag technologies
- launching your app by tapping a tag → Background tag reading
- something not working → Troubleshooting
- every feature at once, one button each → the demo app in
example/
Setup in detail
Everything the two platforms can ask for. The first subsection is what Quick start already did; the rest you add only when you use the feature that needs it.
The minimum, for reading tags
Android — nothing. The plugin declares android.permission.NFC and its card emulation
service itself, so a reader-only app needs no manifest change.
iOS — three things, all of them Quick start step 3:
- The Near Field Communication Tag Reading capability in Xcode.
NFCReaderUsageDescriptioninInfo.plist.com.apple.developer.nfc.readersession.felica.systemcodesinInfo.plist. Pollingiso18092— whichstartSessiondoes by default — makes CoreNFC demand it. Without it the reader sheet simply never appears,startSessionstill returns normally, and the failure arrives asynchronously. Either add the key or dropiso18092frompollingOptions.
Only if you need it
Background tag reading (Android) — only if a tag should launch your app while it is
closed. The intent filters name your activity, so only your manifest can declare them. Add
to the launcher activity, which must be android:launchMode="singleTop" or a tap starts a
second copy instead of delivering to the running one:
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/plain"/>
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.TECH_DISCOVERED"/>
</intent-filter>
<meta-data
android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter"/>
with res/xml/nfc_tech_filter.xml listing the technologies you handle — see
the example. Trim it: every
technology you list makes your app an option on every matching tap.
Card emulation description (Android) — only if you use host card emulation. The string
shown in the system's "Tap and pay" settings defaults to "NFC card emulation". Override it by
declaring nfc_util_hce_description in your own strings.xml.
ISO 7816 tags (iOS) — only if you send APDUs to a smart card. Those tags additionally need
com.apple.developer.nfc.readersession.iso7816.select-identifiers in Info.plist, listing
the AIDs you select.
Apple Wallet passes (iOS) — only if you read Wallet passes. They need VAS in
com.apple.developer.nfc.readersession.formats. This is not part of the Xcode capability,
which grants only NDEF and TAG: the App ID has to be provisioned for VAS separately, and
adding the value to a profile that does not carry it fails the build, not the session —
"Provisioning profile ... doesn't match the entitlements file's value for the
com.apple.developer.nfc.readersession.formats entitlement". The example app therefore ships
without it, so it builds on any team; add it once your own App ID is provisioned.
Troubleshooting
| What you see | Why, and what to do |
|---|---|
| iOS: no reader sheet, and no error either. The call returns and nothing happens. | The FeliCa system codes are missing from Info.plist — step 3. |
The getter 'instance' isn't defined for the type 'NfcUtil' |
A class of your own named NfcUtil shadows the package's. Import it under a prefix — The six libraries. |
PlatformException(session_already_exists) |
A session is still running. Call stopSession() first, and after an error restart only when error.sessionEnded is true — Errors. |
| No tag is ever detected | An emulator or the Simulator (neither has a radio), or NFC is switched off. checkAvailability() tells the two apart. |
| Android: NFC is on, the session starts, and still no tag is ever detected | Tag reading is a switch of its own from Android 15, separate from the adapter, and it is off. isReaderOptionEnabled() reports it and openNfcSettings() takes the user to it — Sessions. |
| Android: the tap opens a different app | Another app claims the tag first. Call enableForegroundDispatch() while your screen is up — Background tag reading. |
| A write appears to do nothing | Check ndef.isWritable, and message.byteLength <= ndef.maxSize, before writing — NDEF. |
| iOS: the session ends by itself | Expected. CoreNFC closes an idle session and reports it through onError. |
| Android 16+: a tag that used to open the app stopped doing it | Either the user switched the app off Launch via NFC, or the tag holds a web link, which now goes to ACTION_VIEW — Android 16 and 17. |
| Android 17: nothing happens on a tap, with nothing in the log | The receiving activity is missing android.permission.DISPATCH_NFC_MESSAGE, or the app is force-stopped. checkTagIntentSetup() reports the first — Android 16 and 17. |
setObserveModeEnabled returns false |
Only the preferred service may change observe mode. Call setPreferredService(true) first — Host card emulation. |
PlatformException(unsupported_api_level) |
The device is older than the API level that capability needs. Where a probe exists — secure NFC, host card emulation, observe mode, the tag-scan allowlist — ask it first; probes answer false rather than throwing. setDiscoveryTechnology has no probe, so catch the exception instead. |
Android 16 and 17
Two releases of Android changed how a tag intent reaches an app, and both fail silently: the tap does nothing, nothing is logged, and the app has no way to notice. Neither affects reader sessions or foreground dispatch, which are the durable way to read a tag and are untouched by everything below.
Ask the platform rather than guessing. One call reports both:
final setup = await android.NfcUtilAndroid.instance.checkTagIntentSetup();
if (!setup.isHealthy) debugPrint('$setup');
Android 16 (API 36): the user can switch your app off
Android 16 added a per-app allowlist, because apps with NFC intent filters were being pulled to the foreground every time the phone touched a credit card or a watch. The switch lives in Settings > Apps > Special app access > Launch via NFC, and the user is asked the first time your app is launched by a tag.
if (!await android.NfcUtilAndroid.instance.isTagIntentAllowed()) {
// Nothing can be granted programmatically; this only takes the user to the switch.
await android.NfcUtilAndroid.instance.openTagIntentPreferenceSettings();
}
isTagIntentAllowed() returns true on a device with no allowlist, so false always means the
user actually said no.
Android 16: web links no longer fire NDEF_DISCOVERED
A tag holding an http:// or https:// URI record now triggers ACTION_VIEW instead. From
Android 17 it does not even do that on its own: the user gets an "open link" notification and
has to act on it.
If your manifest filter matched web URLs, add an ACTION_VIEW filter for your own domain, or
move to Android App Links. Reader sessions still see these tags exactly as before.
Android 17 (API 37): the receiving activity needs a permission
From Android 17, an app targeting API 37 or higher receives NFC intents only on an activity declaring:
android:permission="android.permission.DISPATCH_NFC_MESSAGE"
checkTagIntentSetup().unguardedActivities lists the activities in your app that answer an
NFC intent without it. It finds them by probing — Android exposes no way to read an activity's
intent filters — covering a filter with no data, one with any MIME type, and the http and
https schemes; a filter declaring only some other scheme is not seen.
android:permission guards the whole activity, so it cannot go on a launcher activity
that also carries the NFC filters — the launcher entry would be gated too. Move the filters
to an <activity-alias> that carries the permission, and leave the launcher activity
unguarded. The example's manifest ships
exactly that shape.
It is safe on older devices, and that was measured rather than assumed. The permission is
not new: dumpsys package reports it as declared by the platform itself
(sourcePackage=android, prot=signature|privileged) on both an API 37 Pixel and an API 28
phone, and held by the NFC system service on both (DISPATCH_NFC_MESSAGE: granted=true). API
37 newly enforces an existing permission. So the NFC service can still start a guarded
activity on an old OS, while ordinary apps cannot — which is the point. A guarded
<activity-alias> also registers and resolves TECH_DISCOVERED on both.
Android 17: no dispatch to a stopped app
The system no longer delivers NFC intents to an app that has never been launched by the user
or has been force-stopped. takeInitialTag() returns null in that state until the app has
been opened once.
ACTION_TAG_DISCOVERED is deprecated
Use NDEF_DISCOVERED or TECH_DISCOVERED in new manifests. The plugin still accepts tags
delivered under the old action, because every device up to API 36 still sends it.
The six libraries
Which platforms a class works on is told by the import path, not by a suffix on the class
name. nfc_util.dart, ndef.dart and apdu.dart work everywhere; android.dart and
ios.dart work only on the platform they name.
import 'package:nfc_util/nfc_util.dart'; // NfcUtil, NfcTag, NfcError
import 'package:nfc_util/ndef.dart'; // Ndef, NdefMessage, typed records
import 'package:nfc_util/apdu.dart'; // CommandApdu, StatusWord, Iso7816Chaining
import 'package:nfc_util/android.dart' as android; // android.nfc
import 'package:nfc_util/ios.dart' as ios; // CoreNFC
import 'package:nfc_util/testing.dart'; // test code only: fakes for the platform
ndef.dart and apdu.dart share a property the other four do not: neither needs a tag.
The NDEF wire codec and the ISO 7816-4 encoder are values and bytes, with no platform call
anywhere in them, so both run in a plain flutter test on a machine with no NFC radio — see
NDEF and ISO 7816 APDUs.
testing.dart belongs to a package's tests rather than its lib/. It replaces the platform
side with fakes, which is the only way any CI exercises a tap at all, and everything in it is
inert — no channel is touched and no handle addresses a tag — so an app that reaches for it
from production code ships that inertness to its users. See Testing.
NfcUtil is a thin adapter, and it hides nothing: anything it does not cover is reachable
directly on NfcUtilAndroid or NfcUtilIos.
If your app has its own NfcUtil — a wrapper named after the thing it wraps is the
obvious name on both sides of the import — the result is not a conflict but a shadow: the
local declaration wins, and NfcUtil.instance fails with "The getter 'instance' isn't
defined for the type 'NfcUtil'". Import the package under a prefix:
import 'package:nfc_util/nfc_util.dart' as nfc;
if (await nfc.NfcUtil.instance.checkAvailability() != nfc.NfcAvailability.enabled) return;
await nfc.NfcUtil.instance.startSession(onDiscovered: (tag) async { /* ... */ });
Sessions
if (await NfcUtil.instance.checkAvailability() != NfcAvailability.enabled) return;
await NfcUtil.instance.startSession(
onDiscovered: (tag) async { /* awaited before the platform touches the tag again */ },
onError: (error) async => print(error), // both platforms raise this
pollingOptions: {NfcPollingOption.iso14443},
skipNdefCheck: true, // faster discovery when NDEF does not interest you
alertMessageIos: 'Hold your phone near the tag',
);
checkAvailability separates "no NFC hardware" from "the user switched NFC off", so an app
can offer open settings only when that would help. It never throws.
On Android it is not the only switch. From Android 15 tag reading has one of its own,
and with the adapter on and that one off the session starts, succeeds, and never discovers a
tag — the reading side of the same silence checkTagIntentSetup()
answers on the intent side:
if (!await android.NfcUtilAndroid.instance.isReaderOptionEnabled()) {
await android.NfcUtilAndroid.instance.openNfcSettings(); // only takes the user there
}
It answers true below API 35, where the switch does not exist, so false always means the user
actually turned it off. isReaderOptionSupported() tells the two apart when that matters.
Parameters carrying a platform suffix are ignored on the other platform. A session already
running is rejected with session_already_exists on both platforms.
One session, many tags: pass invalidateAfterFirstReadIos: false. iOS restarts polling
only after your onDiscovered returns, so the tag is never pulled out from under an app
that is still reading it.
A wallet reads as one of the cards in it. When CoreNFC reports several tags in one
detection this package addresses the first, and NfcTag.otherTagCount says how many others
were there — zero for the ordinary single-card tap, null on Android, where reader mode
delivers one tag per callback and the question does not arise. Which card came first is not
deterministic, so the same wallet can read as a different card on consecutive taps:
onDiscovered: (tag) async {
if ((tag.otherTagCount ?? 0) > 0) {
// Several cards were in the field and this is an arbitrary one of them.
}
},
It is a field rather than a callback because an app should not have to opt in to being told its read may have been the wrong card. What to do about it stays yours — asking the user to present one card is a sentence only your app can write, in only its language.
skipNdefCheck on iOS costs you Ndef.from(tag). The probe it skips is what that
constructor is built from, so it answers null even though reading and writing address the tag
by handle and would work perfectly well. Ndef.uncheckedIos(tag) is the way through, and the
only way to express the sequence Apple documents for a protected tag — authenticate first,
touch NDEF second. Its isWritable, maxSize and cachedMessage read false, zero and null
because nothing asked the tag; treat them as unknown and call
ios.NfcUtilIos.instance.ndefQueryStatus(tag.handle) when the real status matters. iOS only:
on Android skipNdefCheck makes the platform leave the technology off the tag altogether, so
there is nothing to reach.
While an iOS session is up you can narrate it and move it along:
await ios.NfcUtilIos.instance.tagSessionSetAlertMessage('Hold still, writing…');
await ios.NfcUtilIos.instance.tagSessionRestartPolling(); // drop this tag, look for the next
NDEF
The record types both build and parse, and the codec is pure Dart — a message can be assembled or decoded with no tag in range, which is what host card emulation and intent payloads need.
final message = NdefMessage([
TextRecord.create('merhaba', languageCode: 'tr'),
UriRecord.create(Uri.parse('https://example.com')),
SmartPosterRecord.create(uri: uri, title: 'Kampanya', action: SmartPosterAction.execute),
]);
final ndef = Ndef.from(tag);
if (ndef != null && ndef.isWritable && message.byteLength <= ndef.maxSize) {
await ndef.write(message);
}
for (final record in (await ndef!.read())?.records ?? const []) {
final text = TextRecord.from(record);
if (text != null) print('${text.languageCode}: ${text.text}');
}
final bytes = message.toBytes(); // NFC Forum wire format
final decoded = NdefMessage.fromBytes(bytes); // chunked records are reassembled
TextRecord, UriRecord, SmartPosterRecord, MimeRecord and ExternalRecord each have
a create and a from, and from returns null rather than throwing on a record of another
kind.
Tag technologies
final classic = android.MifareClassic.from(tag);
if (classic != null && await classic.authenticateSectorWithKeyA(sectorIndex: 1, key: key)) {
final block = await classic.sectorToBlock(sectorIndex: 1);
print(await classic.readBlock(blockIndex: block));
}
final card = ios.Iso7816.from(tag);
final response = await card?.sendCommandRaw(apdu);
if (response?.isSuccess ?? false) print(response!.payload);
Every class has from(tag), returning null when the tag does not answer to it. Fields are
captured at discovery; anything needing a round trip is a Future method.
The Android connection is opened once and held for the session, so a Mifare Classic sector
authentication still applies to the reads that follow it. That cuts both ways: a wrong key
halts the tag while the connection the plugin holds still looks alive, and every command after
it fails too. reset() closes that connection and opens it again, reselecting the tag in the
field, which is what makes trying a list of candidate keys possible without ending the session
and asking for another tap:
final classic = android.MifareClassic.from(tag)!;
for (final key in [android.MifareClassic.keyDefault, android.MifareClassic.keyNfcForum]) {
if (await classic.authenticateSectorWithKeyA(sectorIndex: 1, key: key)) break;
await classic.reset(); // the failed attempt halted the tag; the connection starts over
}
reset() is on every Android technology that carries a connection, and it is not free: the
new connection holds none of the old one's state, so a sector authentication and any
setTimeout are deliberately discarded. MifareClassic.keyDefault,
keyMifareApplicationDirectory and keyNfcForum are the platform's own published keys, each
a getter handing back a fresh list — a shared Uint8List is one stray write away from
breaking authentication process-wide, with a failure that looks like a wrong key.
MifareClassic.blockSize, sizeMini, size1K, size2K and size4K are there too, for
reading what size came back as.
iOS has no equivalent — restarting polling there gives the tag up rather than reselecting it
— but it can answer a question Android cannot: NfcUtilIos.tagIsAvailable(tag) reports
whether that tag is still connected and reachable. It is not a "is a tag nearby" probe, and
it answers false for a tag the session has already let go of, so it is for deciding whether a
half-finished exchange is worth continuing:
if (!await ios.NfcUtilIos.instance.tagIsAvailable(tag)) return; // it left the field
ISO 7816 APDUs
package:nfc_util/apdu.dart is the protocol half of what the platforms hand you. They move
bytes — Iso7816.sendCommandRaw on iOS, IsoDep.transceive on Android — and neither of them
builds an extended-length APDU, says what 6A82 means, or follows a chain.
A response longer than one frame is silently truncated. A card answering 61xx is saying
"here is the first frame, ask again for the rest", and neither CoreNFC nor
android.nfc.tech.IsoDep asks. Code that does not know to loop keeps the first frame and the
status word, and never learns the answer was cut in half. 6Cxx is the mirror image: the card
rejected the Le it was sent, named the length it wants, and ran nothing at all.
import 'package:nfc_util/apdu.dart';
import 'package:nfc_util/ios.dart' as ios;
final card = ios.Iso7816.from(tag)!;
final response = await Iso7816Chaining(card.sendCommandRaw).sendCommand(
CommandApdu(
instructionClass: 0x00,
instructionCode: 0xA4, // SELECT
p1Parameter: 0x04,
p2Parameter: 0x00,
data: applicationId,
expectedResponseLength: 256,
),
);
switch (response.status) {
case StatusWordSuccess():
handle(response.payload); // every frame of it, concatenated
case StatusWordError(reason: StatusWordErrorReason.fileNotFound):
report('no applet with that AID');
case final other:
report('card answered ${other.value.toRadixString(16)}');
}
Iso7816Chaining takes a function rather than a tag, so the same wrapper works on Android,
where a transceive hands back the status word still attached and
Iso7816ResponseApdu.fromBytes splits it off:
final isoDep = android.IsoDep.from(tag)!;
final chain = Iso7816Chaining(
(command) async => Iso7816ResponseApdu.fromBytes(await isoDep.transceive(command)),
);
One call follows at most maxContinuations continuations — 32 by default, about 8 KB at the
256 bytes a short Le can ask for — and throws a StateError rather than hanging the session
on an applet that never stops asking. Iso7816Chaining.sendCommandRaw is the same loop for a
caller who assembles their own APDU bytes.
CommandApdu encodes all four ISO 7816-4 cases and takes the short form whenever the lengths
fit it; forceExtended: true asks for the extended one outright, which finally gives
IsoDep.isExtendedLengthApduSupported something to act on. StatusWord is a sealed hierarchy
rather than an enum, because the interesting status words carry a number —
StatusWordMoreData.remainingBytes, StatusWordWrongLength.correctLength,
StatusWordWarning.retryCounter, the attempts left before a PIN locks — and because a card is
allowed to answer something this package has never heard of. That arrives as
StatusWordUnrecognised with its bytes intact rather than as a crash, which is what keeps a
DESFire card's native 91xx readable.
Chaining is opt-in, and deliberately not wired into transceive or Iso7816.sendCommand.
Protocols layered on ISO 7816 run continuations of their own: DESFire signals "more frames"
with its own AF status and expects the reader to answer AF, and secure messaging wraps
and unwraps each command and response as a unit. A transport that quietly issued GET RESPONSE
underneath either would splice bytes into the middle of a frame the layer above is still
assembling, and corrupt an exchange that was working. Reach for Iso7816Chaining where you
know the card is speaking plain ISO 7816-4.
None of this touches a platform channel, so CommandApdu and StatusWord are exercised in a
plain flutter test with no device — the same property the NDEF codec has.
Background tag reading
Manifest intent filters are the fragile path on Android 16 and 17: the user can switch the
app off the tag-scan allowlist, web-link tags no longer fire NDEF_DISCOVERED, an activity
without DISPATCH_NFC_MESSAGE is never dispatched to, and a force-stopped app gets nothing.
All four fail in silence — see Android 16 and 17, and call
checkTagIntentSetup() to find out which one you are in. Reader sessions and foreground
dispatch are unaffected by every one of them.
// Android: the tag that launched the app, consumed by the first call.
final tag = await android.NfcUtilAndroid.instance.takeInitialTag();
// Android: tags arriving while the app runs.
android.NfcUtilAndroid.instance.onTagFromIntent = (tag) async { /* ... */ };
// Android: claim tags while your app is on screen, so another app cannot take them.
await android.NfcUtilAndroid.instance.enableForegroundDispatch();
// iOS: iPhone XS and later read NDEF tags with no app running. Needs associated domains
// and a tag holding a matching URL.
ios.NfcUtilIos.instance.onNdefFromBackground = (message) { /* ... */ };
final launched = await ios.NfcUtilIos.instance.takeInitialNdefMessage();
Host card emulation
The phone answers a reader as if it were a card. Android only — Apple's equivalent is behind an entitlement that is not generally available.
final hce = android.HostCardEmulation.instance;
if (!await hce.isSupported()) return;
hce.onApduReceived = (apdu) {
final isSelect = apdu.length > 1 && apdu[1] == 0xA4;
hce.respond(Uint8List.fromList(isSelect ? [0x90, 0x00] : [0x6D, 0x00]));
};
await hce.registerAids(['F0010203040506']);
await hce.setPreferredService(true); // while your app is in the foreground
AIDs are registered at run time, so the set can change without a release.
registerAids changes persistent device state. The emulation service ships disabled; a
successful call enables it and stores the AID group with the Android framework, and both
survive the process being killed, a reboot and an app update. The device answers readers for
those AIDs whenever the app is installed, running or not. unregisterAids() is the only way
back short of uninstalling, so pair the two — a call that fails or returns false leaves
nothing behind, but one that succeeds and is never undone leaves the app enrolled forever.
Pick your own AID. F0010203040506 above is a sample: two apps built from it on one device
claim the same identifier, and the second registration is refused.
Everything that writes has a matching read, which is how you find out what the device actually did with it:
if (!await hce.supportsAidPrefixRegistration()) {
// A prefix AID would simply fail here, indistinguishably from a malformed one.
}
await hce.aidsForService(android.CardEmulationCategory.other); // static and dynamic, together
await hce.isDefaultServiceForAid('F0010203040506'); // would a reader reach you?
await hce.isDefaultServiceForCategory(android.CardEmulationCategory.payment);
categoryAllowsForegroundPreference(category) says whether setPreferredService has any
effect at all for that category — on a device where the user's wallet choice is final for
payment, it does not, and the call above would otherwise look like it worked.
selectionModeForCategory(category) reports how the platform picks between apps claiming the
same AID, as AidSelectionMode.preferDefault, askIfConflict, alwaysAsk, or unknown for
a constant this version does not name.
This release bridges APDUs only while the Flutter engine is alive. A tap with the app
fully stopped is answered with 6D00 rather than queued. Emulating a card while the app is
closed needs a background engine, which this release does not have.
Observe mode — watching a reader without answering it
Android 15 (API 35) and above. With observe mode on, the phone stops transacting and reports the reader's polling loop instead, so an app can see which reader it is at and decide what to present before anything is exchanged.
final hce = android.HostCardEmulation.instance;
if (!await hce.isObserveModeSupported()) return;
hce.onPollingFrames = (frames) {
for (final frame in frames) debugPrint('${frame.type.name} ${frame.data}');
};
// The order matters. Only the preferred service may change observe mode, so anything else
// returns false.
await hce.registerPollingLoopFilter(filter: '6A01', autoTransact: false);
await hce.setPreferredService(true);
await hce.setObserveModeEnabled(true);
registerAids is not a prerequisite: an app can watch readers without offering to be a
card. The calls above enable the plugin's emulation service themselves, because the platform
will not make a disabled service the preferred one. That enablement is the same persistent
component state registerAids warns about, and unregisterAids() is the way back from it
either way — it returns false when there were no AIDs to remove, which is not a failure.
autoTransact: true makes the platform leave observe mode by itself the moment a frame
matches, so the exchange that follows is answered rather than watched. That is the low-latency
path — a reader will not wait for a round trip to Dart and back — and the trade is that you
give up the chance to inspect the reader first.
registerPollingLoopPatternFilter does not take a regular expression, despite the name.
Measured on Android 17: the pattern must begin with hex digits and may then use * and ?.
6A* and 6A01 are accepted; .*, a bare *, ???? and *6A* are each rejected with
PlatformException('unavailable', 'Polling loop pattern filters may only contain hexadecimal numbers, ?s and *s'). Case does not matter.
Filters are persistent, like registerAids: removePollingLoopFilter and
removePollingLoopPatternFilter are the way back. setDefaultToObserveMode(true) makes the
service come up in observe mode whenever it becomes preferred, instead of needing a call on
every foreground.
allowOneTransaction(), which lets a single exchange through without leaving observe mode, is
Android 17 and is not in this release.
Card-emulation events
Android 16 (API 36) and above. AID conflicts, unrouted AIDs, preferred-service changes, observe-mode changes, remote-field changes and NFC stack errors, as one stream:
if (await android.NfcUtilAndroid.instance.enableNfcEvents()) {
android.NfcUtilAndroid.instance.onNfcEvent.listen((event) => debugPrint('$event'));
}
Registration is explicit because it costs a framework callback the plugin has to unregister
again; call disableNfcEvents() when you are done, although the plugin also unregisters when
the engine detaches. enableNfcEvents() returns false below API 36.
Apple Wallet passes
await ios.NfcUtilIos.instance.vasSessionBegin(
configurations: [ios.VasCommandConfiguration(passTypeIdentifier: 'pass.com.example.loyalty')],
onResponse: (responses) {
for (final r in responses) {
if (r.status == ios.VasResponseErrorCode.success) print(r.vasData);
}
},
);
Errors
onError reports something going wrong with a session, on both platforms.
onError: (error) async {
switch (error.source) {
case NfcErrorSource.ios when error.iosCode == NfcReaderErrorCode.userCanceled:
break; // the user dismissed the sheet
case NfcErrorSource.android when error.androidCode == NfcAndroidErrorCode.tagLost:
showMessage('Hold the tag still');
default:
report(error.message);
}
// Only restart when the session is actually gone. Every CoreNFC failure ends it, but on
// Android a tag that could not be read leaves reader mode polling -- and starting again
// there is refused with `session_already_exists`, which would leave the app deaf.
if (error.sessionEnded) await restart();
}
Tag operations throw PlatformException with the same codes. An error code this version
does not recognise degrades to unknown rather than throwing.
A reader session and a VAS session have separate callbacks: stopping one leaves the other's
onError and onBecameActive registered, and a start that the platform refuses puts back
whatever was armed before rather than clearing it.
Testing
The NDEF layer and the ISO 7816-4 layer are pure Dart and fully testable on
their own. Everything else needs the platform replaced, and
package:nfc_util/testing.dart is what replaces it. That is a hardware necessity rather than
a convenience: NFCTagReaderSession does not start in the Simulator and no emulator has an
NFC radio, so fakes are the only mechanism by which any CI runs a tag through an app at all.
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:nfc_util/android.dart';
import 'package:nfc_util/testing.dart';
class _Card extends FakeNfcAndroidHostApi {
@override
Future<Uint8List> mifareClassicReadBlock(String handle, int blockIndex) async =>
Uint8List.fromList(List.filled(16, blockIndex));
}
void main() {
late void Function() restore;
setUp(() => restore = debugReplaceApis(android: _Card()));
tearDown(() => restore());
test('reads the block it asked for', () async {
final tag = fakeNfcTag(techs: [FakeTech.nfcA(), FakeTech.mifareClassic()]);
expect(await MifareClassic.from(tag)!.readBlock(blockIndex: 4), everyElement(4));
});
}
debugReplaceApis({nfc, android, ios}) puts a fake platform in place and hands back the
function that puts the real one back. FakeNfcHostApi, FakeNfcAndroidHostApi and
FakeNfcIosHostApi answer every call — availability reads enabled, sessions succeed
quietly, a read comes back null, and the Android fake stands in for the oldest phone this
package runs on, so every later capability answers false — which is what lets a test override
only the calls it asserts on. fakeNfcTag builds the tag the platform would have delivered
out of FakeTech entries; it is inert, its handle addresses nothing, and its techList is
derived from the Android technologies given unless you pass one, because a tag carrying a
technology the platform left off its list is a tag no device would deliver.
Where the boundary still is. Every call that answers a plain Dart value — the transceives,
the Mifare reads, the capability questions — can be overridden with nothing but the import
above. A call naming a generated class or enum anywhere in its signature cannot, and that is
more than the obvious return types: ndefRead and the ISO 7816 exchanges answer one, and
resetTech and the card-emulation queries answer nothing at all but still take one as a
parameter. Overriding those means importing package:nfc_util/src/pigeon.g.dart in the test —
an implementation import the analyzer flags, and a shape that changes without a major version,
which is why it is not re-exported here. Say it with a public type where the two are
interchangeable — a message handed to FakeTech.ndefAndroid and read back as
Ndef.from(tag)?.cachedMessage, rather than an overridden ndefRead — and where they are
not, the import is the price.
The package's own tests are worth reading as worked examples:
test/session_test.dart, and
test/android_platform_test.dart /
test/hce_test.dart for the capability probes and the polling-frame
and event callbacks.
In a widget test with nothing standing in for the platform, a channel call never completes, so an app should treat "availability unknown" as "not ready" rather than assuming a failure will arrive.
What no test can cover, and what a physical device is needed for: host card emulation
needs a reader and a second device; background reading needs the app closed; Wallet passes
need a real pass; NFCTagReaderSession will not start in the Simulator, and no emulator has
an NFC radio.
Upgrading from 2.2.0
3.0.0 is a rewrite. Every import and most names changed, starting with the entry point.
| 2.2.0 | 3.0.0 |
|---|---|
NfcManager.instance |
NfcUtil.instance |
package:nfc_util/platform_tags.dart |
package:nfc_util/android.dart, package:nfc_util/ios.dart |
Ndef, NdefMessage, NdefRecord from nfc_util.dart |
package:nfc_util/ndef.dart |
NdefRecord.createText(...) |
TextRecord.create(...) |
NdefRecord.createUri/createMime/createExternal |
UriRecord.create, MimeRecord.create, ExternalRecord.create |
NdefTypeNameFormat.nfcWellknown / .nfcExternal |
.wellKnown / .external |
startSession(alertMessage:, invalidateAfterFirstRead:, noPlatformSounds:, discoverNfcBarcode:) |
same options, platform-suffixed: alertMessageIos:, invalidateAfterFirstReadIos:, noPlatformSoundsAndroid:, discoverNfcBarcodeAndroid: |
stopSession(alertMessage:, errorMessage:) |
stopSession(alertMessageIos:, errorMessageIos:) |
NfcManager.instance.onAdapterStateChanged |
NfcUtilAndroid.instance.onAdapterStateChanged |
NfcManager.instance.isSecureNfcSupported() |
NfcUtilAndroid.instance.isSecureNfcSupported() |
isAvailable() (deprecated in 2.1.0) |
removed — use checkAvailability() |
NfcError.type (NfcErrorType) |
removed — use error.source with iosCode / androidCode, and sessionEnded to decide whether to restart |
tag.data['nfca']['identifier'] |
tag.id |
MifareClassic.type (int) |
MifareClassicType enum |
setTimeout(int) / timeout as int |
Duration |
Ndef.canMakeReadOnly |
NdefAndroid.from(tag)?.canMakeReadOnly |
onDiscovered had no Android error channel |
onError fires on both platforms |
New in 3.0.0 with no 2.2.0 equivalent: the NDEF wire codec and typed record parsing, smart
posters, background tag reading, host card emulation, Apple VAS,
NfcUtilIos.tagSessionRestartPolling, tagSessionSetAlertMessage and
vasSessionSetAlertMessage, raw NfcUtilAndroid.enableReaderMode, foreground dispatch,
configurable presence-check delay, and typed Android error codes.
Roadmap
ROADMAP.md says what is deliberately not in this release and why: the three separate toolchain floors the remaining Android and iOS surface would impose — API 36.1 is a much smaller step than API 37, and most of what looks like Android 17 is actually 36.1 — what the two-device hardware run did and did not cover, three maintenance items found by reading the SDKs, and the APIs this package should not try to expose at all.
License
MIT © Önder ADA
Libraries
- android
- The Android surface: everything
android.nfcoffers and this package exposes. - apdu
- ISO 7816-4 command APDUs, status words, and response chaining.
- ios
- The iOS surface: everything CoreNFC offers and this package exposes.
- ndef
- NDEF values and the NFC Forum wire codec.
- nfc_util
- Cross-platform NFC.
- testing
- Fakes that stand in for the platform, so a tap can be exercised in a test. Test code only.