Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,15 @@
## 3.6.2

* New `androidReaderModeFlags` parameter for `poll()` method to customize Android Reader Mode behavior (#225)
* Add option to specify `EXTRA_READER_PRESENCE_CHECK_DELAY` on Android (#228)
* Add option to specify `EXTRA_READER_PRESENCE_CHECK_DELAY` on Android (#228)

## Unreleased

* iOS: surface `SystemIsBusy` session error as `PlatformException` code `503`, and
`SessionTerminatedUnexpectedly` as code `502`
* iOS: fix `UserCanceled` not surfacing (or surfacing as a generic `500`) when the
session is canceled after a tag was polled — session-invalidation errors are now
delivered to the in-flight operation
* the `409` cancel message is renamed `SessionCanceled` → `UserCanceled` (minor
behavior change for consumers matching on the message string)
* Example app: add buttons to validate `UserCanceled` / `SystemIsBusy`
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ Refer to the [documentation](https://pub.dev/documentation/flutter_nfc_kit/) for

We use error codes with similar meaning as HTTP status code. Brief explanation and error cause in string (if available) will also be returned when an error occurs.

On iOS, session-invalidation errors are surfaced as a `PlatformException` whose `code` follows the same convention and whose `message` is the CoreNFC reason:

| Code | Message | Cause (CoreNFC) |
|------|---------|-----------------|
| `408` | `SessionTimeOut` | `readerSessionInvalidationErrorSessionTimeout` |
| `409` | `UserCanceled` | `readerSessionInvalidationErrorUserCanceled` (user tapped Cancel) |
| `502` | `SessionTerminatedUnexpectedly` | `readerSessionInvalidationErrorSessionTerminatedUnexpectedly` |
| `503` | `SystemIsBusy` | `readerSessionInvalidationErrorSystemIsBusy` |

These are iOS-only (CoreNFC concepts); Android and Web do not produce them. They are delivered to whichever call is pending when the session is invalidated — including a `transceive`/`readNDEF`/`writeNDEF` in progress after a tag was polled.

### Operation Mode

We provide two operation modes: polling (default) and event streaming. Both can give the same `NFCTag` object. Please see [example](example/example.md) for more details.
Expand Down
133 changes: 133 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
NFCAvailability _availability = NFCAvailability.not_supported;
NFCTag? _tag;
String? _result, _writeResult, _mifareResult;
String? _sessionErrorResult;
bool _streaming = false;
int _streamCount = 0;
late TabController _tabController;
List<ndef.NDEFRecord>? _records;

Expand Down Expand Up @@ -80,6 +83,110 @@ class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
});
}

// A benign command used to keep the session busy. Exact bytes depend on the
// tag; unsupported-command errors are ignored while streaming, so any tag works.
String _dummyCommandFor(NFCTag tag) {
if (tag.type == NFCTagType.iso18092) return "060080080100";
return "00A4040000"; // ISO7816 SELECT (no AID)
}

// Validates iOS session-invalidation errors by keeping the session genuinely
// busy: after polling, it continuously transceives dummy data until you stop
// it (tap again) or a session error fires. Tap Cancel on the iOS sheet while
// streaming to see UserCanceled (409) surface mid-transceive. SystemIsBusy
// (503) may also surface here. Both are CoreNFC-only (iOS).
Future<void> _streamDummyData() async {
if (_streaming) {
setState(() => _streaming = false); // request stop
return;
}
setState(() {
_streaming = true;
_streamCount = 0;
_sessionErrorResult = 'Polling…';
});
try {
NFCTag tag = await FlutterNfcKit.poll(
iosAlertMessage: "Streaming — tap Cancel to test UserCanceled");
final dummy = _dummyCommandFor(tag);
while (_streaming) {
try {
await FlutterNfcKit.transceive(dummy);
} on PlatformException catch (e) {
// Only a transient tag-level comm error (500, e.g. the tag rejecting
// our dummy command) is ignored so streaming continues. Any other code
// means the session ended — canceled (409), busy (503), timed out
// (408), terminated unexpectedly (502), or no longer active (406) —
// so stop and report it instead of spinning.
if (e.code != '500') rethrow;
}
setState(() {
_streamCount++;
_sessionErrorResult = 'Streaming dummy data… ($_streamCount sent)';
});
await Future.delayed(const Duration(milliseconds: 100));
}
await FlutterNfcKit.finish(iosAlertMessage: "Stopped");
setState(() =>
_sessionErrorResult = 'Stopped after $_streamCount commands.');
} on PlatformException catch (e) {
setState(() => _sessionErrorResult = 'code=${e.code} message=${e.message}');
} finally {
setState(() => _streaming = false);
}
}

// Drives the real SystemIsBusy (503) flow: stream dummy data until iOS kicks
// us off (502 SessionTerminatedUnexpectedly), then retry immediately — too
// soon, so iOS returns SystemIsBusy (503). It then backs off and retries to
// show the session recovers. iOS-only (CoreNFC).
Future<void> _testSystemIsBusy() async {
setState(() => _sessionErrorResult = 'Streaming until iOS kicks us off…');
try {
// 1. Hold the session busy until iOS terminates it.
NFCTag tag = await FlutterNfcKit.poll(
iosAlertMessage: "Hold still until kicked off…");
final dummy = _dummyCommandFor(tag);
var sent = 0;
while (true) {
try {
await FlutterNfcKit.transceive(dummy);
setState(() =>
_sessionErrorResult = 'Streaming… (${++sent} sent), waiting to be kicked off');
} on PlatformException catch (e) {
if (e.code == '500') continue; // transient tag error, keep streaming
// Session ended (expected: 502 kicked off) — break out to retry.
setState(() => _sessionErrorResult =
'Kicked off after $sent: code=${e.code} (${e.message}). Retrying instantly…');
break;
}
}

// 2. Retry immediately — too soon, so iOS should report SystemIsBusy.
try {
await FlutterNfcKit.poll(
timeout: const Duration(seconds: 5), iosAlertMessage: "Instant retry");
await FlutterNfcKit.finish();
setState(() => _sessionErrorResult =
'Instant retry unexpectedly succeeded (no SystemIsBusy this run).');
return;
} on PlatformException catch (e) {
setState(() => _sessionErrorResult =
'Instant retry → code=${e.code} message=${e.message}');
if (e.code != '503') return; // demo only continues if we actually got busy
}

// 3. Back off, then retry — the session should now recover.
await Future.delayed(const Duration(seconds: 3));
await FlutterNfcKit.poll(iosAlertMessage: "Retry after backoff");
await FlutterNfcKit.finish();
setState(() => _sessionErrorResult =
'Got 503 SystemIsBusy on instant retry; recovered after 3s backoff. ✓');
} on PlatformException catch (e) {
setState(() => _sessionErrorResult = 'code=${e.code} message=${e.message}');
}
}

@override
Widget build(BuildContext context) {
return MaterialApp(
Expand Down Expand Up @@ -154,6 +261,32 @@ class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
child: Text('Start polling'),
),
const SizedBox(height: 10),
const Divider(),
const Text('Session error test (iOS)'),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton(
onPressed: _streamDummyData,
child:
Text(_streaming ? 'Stop streaming' : 'Stream dummy data'),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: _streaming ? null : _testSystemIsBusy,
child: const Text('Test SystemIsBusy'),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(_sessionErrorResult == null
? 'Stream dummy data to keep the session busy, then tap Cancel '
'on the iOS sheet to see UserCanceled. SystemIsBusy is '
'OS-driven and may not reproduce every run.'
: 'Session error: $_sessionErrorResult')),
const Divider(),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _tag != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,39 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
let instance = FlutterNfcKitPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}

/// Map a CoreNFC error to the plugin's HTTP-style FlutterError (single source of truth).
private func mapNFCError(_ error: Error) -> FlutterError {
guard let nfcError = error as? NFCReaderError else {
return FlutterError(code: "500", message: "Invalidate session with error", details: error.localizedDescription)
}
switch nfcError.errorCode {
case NFCReaderError.Code.readerSessionInvalidationErrorUserCanceled.rawValue:
return FlutterError(code: "409", message: "UserCanceled", details: error.localizedDescription)
case NFCReaderError.Code.readerSessionInvalidationErrorSessionTimeout.rawValue:
return FlutterError(code: "408", message: "SessionTimeOut", details: error.localizedDescription)
case NFCReaderError.Code.readerSessionInvalidationErrorSystemIsBusy.rawValue:
return FlutterError(code: "503", message: "SystemIsBusy", details: error.localizedDescription)
case NFCReaderError.Code.readerSessionInvalidationErrorSessionTerminatedUnexpectedly.rawValue:
return FlutterError(code: "502", message: "SessionTerminatedUnexpectedly", details: error.localizedDescription)
default:
return FlutterError(code: "500", message: "Generic NFC Error", details: error.localizedDescription)
}
}

/// Track an operation's result so it completes exactly once — either by the
/// operation's own callback or by session invalidation. Returns a wrapped
/// FlutterResult; shadow the local `result` with it so the existing
/// `result(...)` calls in that branch route through it unchanged.
private func trackResult(_ result: @escaping FlutterResult) -> FlutterResult {
self.result = result
return { [weak self] response in
guard let self = self else { result(response); return }
guard self.result != nil else { return } // already delivered (e.g. by invalidation)
self.result = nil
result(response)
}
}

// from FlutterPlugin
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
Expand Down Expand Up @@ -85,6 +118,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
session?.begin()
}
} else if call.method == "transceive" {
let result = trackResult(result)
if tag != nil {
let req = (call.arguments as? [String: Any?])?["data"]
if req != nil, req is String || req is FlutterStandardTypedData {
Expand Down Expand Up @@ -192,6 +226,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
result(FlutterError(code: "406", message: "No tag polled", details: nil))
}
} else if call.method == "readBlock" {
let result = trackResult(result)
let arguments = call.arguments as! [String : Any?]
if case let .iso15693(tag) = tag {
let rawFlags = (arguments["iso15693Flags"] as? UInt8) ?? 0
Expand Down Expand Up @@ -226,6 +261,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
result(FlutterError(code: "405", message: "readBlock not supported on this type of card", details: nil))
}
} else if call.method == "writeBlock" {
let result = trackResult(result)
let arguments = call.arguments as! [String : Any?]
let data = (arguments["data"] as! FlutterStandardTypedData).data
if case let .iso15693(tag) = tag {
Expand Down Expand Up @@ -267,6 +303,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
result(FlutterError(code: "405", message: "writeBlock not supported on this type of card", details: nil))
}
} else if call.method == "readNDEF" {
let result = trackResult(result)
if tag != nil {
var ndefTag: NFCNDEFTag?
switch tag {
Expand Down Expand Up @@ -331,6 +368,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
result(FlutterError(code: "406", message: "No tag polled", details: nil))
}
} else if call.method == "writeNDEF" {
let result = trackResult(result)
if tag != nil {
var ndefTag: NFCNDEFTag?
switch tag {
Expand Down Expand Up @@ -425,6 +463,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
result(FlutterError(code: "406", message: "Session not active", details: nil))
}
} else if call.method == "makeNdefReadOnly" {
let result = trackResult(result)
if tag != nil {
var ndefTag: NFCNDEFTag?
switch tag {
Expand Down Expand Up @@ -464,24 +503,13 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe

// from NFCTagReaderSessionDelegate
public func tagReaderSession(_: NFCTagReaderSession, didInvalidateWithError error: Error) {
guard result != nil else { return; }

if let nfcError = error as? NFCReaderError {
NSLog("Got NFCError when reading NFC: %@", nfcError.localizedDescription)
switch nfcError.errorCode {
case NFCReaderError.Code.readerSessionInvalidationErrorUserCanceled.rawValue:
result?(FlutterError(code: "409", message: "SessionCanceled", details: error.localizedDescription))
case NFCReaderError.Code.readerSessionInvalidationErrorSessionTimeout.rawValue:
result?(FlutterError(code: "408", message: "SessionTimeOut", details: error.localizedDescription))
default:
result?(FlutterError(code: "500", message: "Generic NFC Error", details: error.localizedDescription))
}
} else {
NSLog("Got unknown when reading NFC: %@", error.localizedDescription)
result?(FlutterError(code: "500", message: "Invalidate session with error", details: error.localizedDescription))
NSLog("NFC session invalidated: %@", error.localizedDescription)
// Deliver the session error to the pending operation (poll or any in-flight
// tag operation tracked via trackResult), if it has not completed already.
if result != nil {
result?(mapNFCError(error))
result = nil
}

result = nil
session = nil
tag = nil
}
Expand Down
Loading