π‘ NFC Util
A Flutter plugin for reading and writing NFC tags on Android and iOS.
It keeps the familiar nfc_manager v3 API β migrating is usually just a change of import β
but ships its own Android and iOS implementations so it can be updated independently and
keep pace with current Flutter, Android and iOS releases.
| π€ Android | Full tag I/O β NDEF, transceive, MIFARE Classic / Ultralight, NDEF formatting, barcode |
| π iOS | NDEF, FeliCa, ISO7816, ISO15693, MIFARE |
| πΆ Availability | Tri-state checkAvailability(), plus a stream of NFC on/off changes on Android |
| π§ Diagnostics | 24 distinct CoreNFC error codes on iOS, typed tag errors on Android |
| π¦ Packaging | CocoaPods and Swift Package Manager |
| π Privacy | PrivacyInfo.xcprivacy bundled automatically, no configuration needed |
π Install
dependencies:
nfc_util: ^2.2.0
π Compatibility
| Requirement | Version |
|---|---|
| Flutter | 3.44.0+ |
| Dart | 3.10.7+ |
Android minSdk |
24 |
Android compileSdk |
36 |
| iOS deployment target | 15.6+ |
| Xcode | 15+ |
Flutter 3.44.0 is the first release that stages the
FlutterFrameworkSwift package that this plugin'sPackage.swiftdepends on. Xcode 15 is the floor for theswift-tools-version:5.9manifest β your Flutter version may require a newer Xcode than that.
βοΈ Setup
π€ Android
Add the NFC permission to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.NFC" />
If your app's minSdk is below 24, raise it in android/app/build.gradle β the plugin
requires 24 and the build fails otherwise:
defaultConfig {
minSdk = 24
}
Nothing else. The plugin ships its own manifest and Gradle configuration.
π iOS
1. Entitlement. Add Near Field Communication Tag Reader Session Formats
to ios/Runner/Runner.entitlements:
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>NDEF</string>
<string>TAG</string>
</array>
2. Usage description. Add NFCReaderUsageDescription
to ios/Runner/Info.plist β the text shown on the scan sheet.
3. Deployment target. In Xcode, open ios/Runner.xcworkspace β Runner target β
Build Settings β iOS Deployment Target and set it to 15.6 or higher. If you use
CocoaPods, ios/Podfile must agree:
platform :ios, '15.6'
A lower target fails at install time under SwiftPM
(requires minimum platform version 15.6) and at pod install under CocoaPods.
4. Tag-specific keys, if you use those technologies β both go in
ios/Runner/Info.plist:
| Key | Needed for |
|---|---|
com.apple.developer.nfc.readersession.felica.systemcodes |
polling iso18092 (FeliCa) |
com.apple.developer.nfc.readersession.iso7816.select-identifiers |
before an ISO7816 tag reports its applications |
π‘ Despite the
com.apple.developer.prefix, these two areInfo.plistkeys, not entitlements. Only...readersession.formatsabove belongs inRunner.entitlements.
β οΈ Important: FeliCa polling configuration
startSessionpolls every option when you omitpollingOptionsβ includingiso18092. CoreNFC rejects a FeliCa session unlesscom.apple.developer.nfc.readersession.felica.systemcodesis in yourInfo.plist.The symptom is easy to misread: the scan sheet never appears, yet
startSessioncompletes normally, because iOS reports the failure asynchronously. With noonErrorcallback you get total silence. With one, you getNfcErrorType.unknown: Missing required entitlementβ despite the wording, the fix is theInfo.plistkey, not an entitlement.Fix it either way:
// Ask for only what you need... NfcManager.instance.startSession( pollingOptions: {NfcPollingOption.iso14443}, onDiscovered: (tag) async { /* ... */ }, onError: (error) async => debugPrint('$error'), // ...and always pass this );
π Usage
π Session
switch (await NfcManager.instance.checkAvailability()) {
case NfcAvailability.disabled:
return promptUserToEnableNfcInSettings();
case NfcAvailability.unsupported:
return showUnsupportedDeviceMessage();
case NfcAvailability.enabled:
break;
}
await NfcManager.instance.startSession(
onDiscovered: (NfcTag tag) async {
// Read and write here β the session stays open until you stop it.
},
onError: (NfcError error) async {
// iOS only. See the error model below.
},
);
await NfcManager.instance.stopSession();
checkAvailability() never throws and tells you why NFC is unusable:
| Android | iOS | |
|---|---|---|
enabled |
NfcAdapter.isEnabled |
NFCTagReaderSession.readingAvailable |
disabled |
hardware present, user switched NFC off | never β iOS has no NFC toggle |
unsupported |
no NFC hardware, or the platform could not answer | no NFC hardware, or the platform could not answer |
Only the disabled case is worth sending the user to system settings for, which is exactly
the distinction the older isAvailable() could not make. isAvailable() still works but is
deprecated and will be removed in 3.0.0.
alertMessage / errorMessage set the text on the iOS scan sheet.
invalidateAfterFirstRead (iOS, default true) stops polling for further tags after
the first one. Either way the session stays open so you can run tag commands inside
onDiscovered; close it yourself with stopSession().
Three Android-only switches shape reader mode:
| Parameter | Default | Effect |
|---|---|---|
noPlatformSounds |
true |
Suppresses the system tag-discovery sound. This default is the opposite of nfc_manager's β it preserves what nfc_util has always done. |
skipNdefCheck |
false |
Skips the platform's NDEF probe, so discovery is measurably faster. Ndef.from(tag) then returns null. |
discoverNfcBarcode |
false |
Also discovers barcode (Kovio) tags. Without it NfcBarcode.from(tag) is always null. |
Starting a session while one is already running throws PlatformException('session_already_exists')
on iOS. Call stopSession() first.
πΆ Reacting to the NFC toggle
checkAvailability() is a snapshot. To follow the user switching NFC on or off while your
app is open, watch the stream β Android only, since iOS has no NFC toggle:
NfcManager.instance.onAdapterStateChanged.listen((state) {
if (state == NfcAdapterState.on) resumeScanning();
});
States are off, turningOn, on and turningOff. Only on is ready β the adapter
refuses work in the two transitional states. The stream does not replay the current state,
so call checkAvailability() once at startup and let the stream take it from there.
Secure NFC (isSecureNfcSupported() / isSecureNfcEnabled()) reports whether the device
restricts tag reading to an unlocked screen. Android API 29+; false below that and on iOS.
π§ Error model
Which failures you catch, and where, differs by platform:
| Where | Android | iOS |
|---|---|---|
startSession throws PlatformException |
NFC unavailable, no attached activity | the session object could not be created |
onError callback |
never called | user cancel, 60 s timeout, session rejected after it began |
Tag operations throw PlatformException |
β | β |
The key asymmetry: on iOS a session can fail after startSession has already returned
successfully, and that failure arrives only on onError β never as a thrown exception.
On Android there is no such asynchronous path, so onError is never invoked.
try {
await NfcManager.instance.startSession(
onDiscovered: (tag) async { /* ... */ },
onError: (error) async {
// `type` is the coarse category: userCanceled / sessionTimeout / systemIsBusy / unknown.
// `code` is the exact CoreNFC failure β read this one when `type` says `unknown`.
switch (error.code) {
case NfcReaderErrorCode.tagConnectionLost:
print('Tag moved away mid-read');
case NfcReaderErrorCode.radioDisabled:
print('NFC radio is off');
default:
print('${error.type.name} / ${error.code?.name}: ${error.message}');
}
},
);
} on PlatformException catch (e) {
// Both platforms: the session never started.
}
NfcErrorType has four values and collapses most CoreNFC failures into unknown.
NfcError.code is the precise code (NfcReaderErrorCode: 24 CoreNFC codes plus
unknown) β a lost tag, a
disabled radio, a too-small tag and a security violation are all distinguishable there. It is
null on Android, and null on iOS for failures that did not originate in CoreNFC.
stopSession() never throws β every platform call inside it is guarded, so it is safe in
finally and dispose.
π·οΈ Tag technologies
Get an instance with the from factory. It returns null when the tag does not
support that technology:
final ndef = Ndef.from(tag);
if (ndef == null) {
print('Tag is not NDEF compatible');
return;
}
final message = await ndef.read();
if (message == null) {
print('Tag is NDEF formatted but empty');
} else {
for (final record in message.records) {
print(record.payload);
}
}
await ndef.write(NdefMessage([NdefRecord.createText('Hello')]));
| Class | π€ Android | π iOS |
|---|---|---|
Ndef |
β | β |
NfcA NfcB NfcF NfcV |
β | β |
IsoDep |
β | β |
MifareClassic |
β | β |
MifareUltralight |
β | β |
NdefFormatable |
β | β |
NfcBarcode |
β ΒΉ | β |
FeliCa |
β | β |
Iso7816 |
β | β |
Iso15693 |
β | β |
MiFare |
β | β |
ΒΉ Requires startSession(discoverNfcBarcode: true). It is a read-only tag with no
operations, so the class carries data only.
Class names follow each platform's own vocabulary: MiFare mirrors Apple's NFCMiFareTag,
while MifareClassic and MifareUltralight mirror Android's android.nfc.tech.* classes.
MIFARE Classic geometry (Android). Sector layout is not uniform β a 4K card has 32 sectors of 4 blocks followed by 8 of 16 β so map between blocks and sectors with the helpers rather than by hand:
final classic = MifareClassic.from(tag)!;
final sector = await classic.blockToSector(blockIndex: 40);
final first = await classic.sectorToBlock(sectorIndex: sector);
final count = await classic.getBlockCountInSector(sectorIndex: sector);
Timeouts (Android). timeout and maxTransceiveLength are snapshots taken at discovery.
To change the timeout β worth doing before a slow exchange, which otherwise fails as
tag_lost β use the async accessors:
final isoDep = IsoDep.from(tag);
await isoDep?.setTimeout(2000); // ms, applies to this connection only
print(await isoDep?.getTimeout());
getMaxTransceiveLength |
getTimeout / setTimeout |
|
|---|---|---|
IsoDep NfcA NfcF MifareClassic MifareUltralight |
β | β |
NfcB NfcV |
β | β (no such API in android.nfc.tech) |
Locking (Android). Check ndef.canMakeReadOnly before calling ndef.writeLock() β not
every tag supports it, and locking cannot be undone. The field is null on iOS, which does not
report it.
Import the platform classes from a second library:
import 'package:nfc_util/nfc_util.dart'; // NfcManager, NfcTag, Ndef, NdefMessage
import 'package:nfc_util/platform_tags.dart'; // NfcA, MiFare, FeliCa, ...
βοΈ Building NDEF records
NdefMessage([
NdefRecord.createText('Hello World!', languageCode: 'en'),
NdefRecord.createUri(Uri.parse('https://flutter.dev')),
NdefRecord.createMime('text/plain', Uint8List.fromList('Hello'.codeUnits)),
NdefRecord.createExternal('com.example', 'mytype', Uint8List.fromList('data'.codeUnits)),
]);
createExternal writes an NFC External Type record, for payloads only your own app
understands. The first argument is a domain you control, in reverse-DNS form
(com.example), and the second is a type name you choose within that domain. Both are
lowercased and joined as domain:type, so keep them stable β changing either makes
previously written tags unreadable to your app.
The create* factories enforce the NDEF specification and throw ArgumentError on invalid
input. NdefRecord.fromPlatform skips that validation and is what the plugin uses to
decode records read off a tag, which may legitimately hold shapes the creation rules
reject β a chunked record, for instance.
π§― Error codes
Tag operations throw PlatformException with these codes:
| Code | Meaning |
|---|---|
invalid_parameter |
Unknown tag handle, or the tag does not support that technology |
tag_lost |
The tag moved out of range mid-operation |
io_exception |
Communication with the tag failed |
no_result |
The platform returned no value for a call that requires one |
unavailable |
NFC is unavailable, or the session could not be started |
no_activity |
Android only β no activity is attached to the plugin |
session_already_exists |
iOS only β a session is already running; call stopSession() first |
On Android the message says whether the failure happened while connecting or while
running the command, e.g. connect: TagLostException: .... Failures are also logged
under the NfcUtilPlugin logcat tag.
π Migrating from nfc_manager
The API mirrors nfc_manager v3, so for most apps the migration is the import:
// Before
import 'package:nfc_manager/nfc_manager.dart';
import 'package:nfc_manager/platform_tags.dart';
// After
import 'package:nfc_util/nfc_util.dart';
import 'package:nfc_util/platform_tags.dart';
NfcManager, NfcTag, NfcPollingOption, Ndef, NdefMessage, NdefRecord and every
platform tag class keep their names, constructors and method signatures. Three differences
are worth knowing:
| Change | Why |
|---|---|
Ndef.read() returns NdefMessage? |
An NDEF-formatted but empty tag is normal. The old non-null signature crashed with a TypeError instead of reporting it. |
MifareClassic.transceive takes Uint8List data |
The inherited signature declared int, which cannot carry a command. |
FeliCa.requestSpecificationVersion() works |
It invoked a method name no platform implemented and always threw MissingPluginException. |
Android users gain the most: every tag I/O method β Ndef read/write, transceive on all
technologies, the MIFARE Classic and Ultralight commands, and NDEF formatting β is
implemented here.
Coming from nfc_manager v4 instead? That release renamed most of the API
(NfcA β NfcAAndroid, MiFare β MiFareIos, onError β onErrorIos, Ndef split into
NdefAndroid/NdefIos). nfc_util keeps the v3 names, so drop the suffixes. The v4 features
worth having are here under the original naming: checkAvailability(), setTimeout(),
detailed CoreNFC error codes and noPlatformSounds.
π¦ CocoaPods and Swift Package Manager
Both are supported and share one copy of the native sources under
ios/nfc_util/Sources/nfc_util/, so the two paths can never drift apart. The privacy
manifest ships either way, with no configuration on your side.
You do not pick per-plugin: Flutter uses Swift Package Manager when it is enabled, which is the default, and CocoaPods otherwise. To force CocoaPods:
flutter config --no-enable-swift-package-manager
π± Example
The example/ app exercises the plugin on a real device: read a tag, write
NDEF, lock a tag, read a barcode tag, and a Tag I/O button that runs read-only commands
against every technology the tag supports and reports each result with timings β including
the timeout accessors. It also re-checks availability whenever the NFC adapter is switched
on or off, and prints both the coarse type and the exact CoreNFC code for session errors.
NFC hardware does not exist in emulators or the iOS Simulator β the example needs a physical device.
π Links
π License
MIT Β© Γnder ADA