πŸ“‘ 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 FlutterFramework Swift package that this plugin's Package.swift depends on. Xcode 15 is the floor for the swift-tools-version:5.9 manifest β€” 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 are Info.plist keys, not entitlements. Only ...readersession.formats above belongs in Runner.entitlements.

⚠️ Important: FeliCa polling configuration

startSession polls every option when you omit pollingOptions β€” including iso18092. CoreNFC rejects a FeliCa session unless com.apple.developer.nfc.readersession.felica.systemcodes is in your Info.plist.

The symptom is easy to misread: the scan sheet never appears, yet startSession completes normally, because iOS reports the failure asynchronously. With no onError callback you get total silence. With one, you get NfcErrorType.unknown: Missing required entitlement β€” despite the wording, the fix is the Info.plist key, 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.



πŸ“„ License

MIT Β© Γ–nder ADA