From 0e92b54d51a82c189c42ecdb2eaffb540e494e27 Mon Sep 17 00:00:00 2001 From: Ivan Herrera Olivares Date: Tue, 2 Jun 2026 21:42:34 +0200 Subject: [PATCH] iOS: surface SystemIsBusy/SessionTerminatedUnexpectedly and fix UserCanceled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session-invalidation errors were only delivered to a pending `poll`; any in-flight tag operation (transceive/readNDEF/writeNDEF/...) used a local result closure, so when the user canceled after a tag was polled the `UserCanceled` error was dropped by the `guard result != nil` check — the Future hung or surfaced the operation's generic 500 instead. - Add a single `mapNFCError` mapper covering UserCanceled (409), SessionTimeOut (408), SystemIsBusy (503) and SessionTerminatedUnexpectedly (502); previously the latter two fell through to a generic 500. - Route in-flight operations through a fire-once `trackResult` wrapper so `didInvalidateWithError` completes the pending call exactly once. - Rename the 409 message `SessionCanceled` -> `UserCanceled`. - Example: add a "Session error test" section (stream dummy data to keep the session busy for UserCanceled; drive the kick-off -> instant retry -> backoff flow for SystemIsBusy). - Document the iOS session-error codes in README and CHANGELOG. --- CHANGELOG.md | 13 +- README.md | 11 ++ example/lib/main.dart | 133 ++++++++++++++++++ .../flutter_nfc_kit/FlutterNfcKitPlugin.swift | 62 +++++--- 4 files changed, 201 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c4b0c..116baed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) \ No newline at end of file +* 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` diff --git a/README.md b/README.md index efb9fd7..c4f6c12 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/example/lib/main.dart b/example/lib/main.dart index 0d460ca..348905e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -31,6 +31,9 @@ class _MyAppState extends State 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? _records; @@ -80,6 +83,110 @@ class _MyAppState extends State 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 _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 _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( @@ -154,6 +261,32 @@ class _MyAppState extends State with SingleTickerProviderStateMixin { child: Text('Start polling'), ), const SizedBox(height: 10), + const Divider(), + const Text('Session error test (iOS)'), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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 diff --git a/ios/flutter_nfc_kit/Sources/flutter_nfc_kit/FlutterNfcKitPlugin.swift b/ios/flutter_nfc_kit/Sources/flutter_nfc_kit/FlutterNfcKitPlugin.swift index 3993122..db8f374 100644 --- a/ios/flutter_nfc_kit/Sources/flutter_nfc_kit/FlutterNfcKitPlugin.swift +++ b/ios/flutter_nfc_kit/Sources/flutter_nfc_kit/FlutterNfcKitPlugin.swift @@ -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) { @@ -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 { @@ -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 @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 }