Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
channel: ${{ matrix.channel }}
cache: true
- run: flutter pub get
- run: flutter test
- run: dart format --output=none --set-exit-if-changed lib/
- run: dart analyze
- name: Install dartdoc
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,6 @@
* Update Android build tooling to AGP 9.0.1 and Kotlin 2.3.20; AGP 9 release builds using optimized resource shrinking require `android.nonFinalResIds=true`.

## Unreleased

* Add cross-platform `checkNDEF` to control automatic NDEF discovery on Android and iOS (enabled by default). Deprecate `androidCheckNDEF`, retaining it as an explicit Android-only override for compatibility.
* Release iOS reader state after invalidation even when `poll()` has already returned, clean up failed connections, and ignore stale discovery/setup callbacks.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,34 @@ We use error codes with similar meaning as HTTP status code. Brief explanation a

We provide two operation modes: polling (default) and event streaming. Both can give the same `NFCTag` object. Please see the [example](https://github.com/nfcim/flutter_nfc_kit/blob/master/example/example.md) for more details.

### Skipping automatic NDEF discovery (Android and iOS)

Applications that communicate with a card through raw commands can skip the
initial NDEF check on both platforms:

```dart
final tag = await FlutterNfcKit.poll(
checkNDEF: false,
);
```

`checkNDEF` defaults to `true` and is ignored on Web. On Android, setting it to
`false` sets `FLAG_READER_SKIP_NDEF_CHECK`, so the platform's NDEF methods are
unavailable for that session. On iOS,
`checkNDEF: false` skips `queryNDEFStatus` after connecting: NDEF metadata stays
at its default values (`false`, zero or empty), even for an NDEF-capable tag.
The iOS NDEF methods still contact the tag when explicitly invoked. Raw
`transceive` commands remain available on both platforms. FeliCa PMm metadata
is still collected on iOS.

This avoids an extra exchange before the first application command. The time
saved depends on the device and tag; it does not change the polling technology
selection or guarantee a higher discovery success rate.

The existing `androidCheckNDEF` parameter is deprecated. If explicitly provided,
it overrides `checkNDEF` on Android only, preserving existing Android-only
behavior. Replace it with `checkNDEF` to control discovery on both platforms.

### Performance Optimization (Android)

For Android applications, you can optimize NFC tag detection performance using the `androidReaderModeFlags` parameter:
Expand Down
177 changes: 105 additions & 72 deletions ios/flutter_nfc_kit/Sources/flutter_nfc_kit/FlutterNfcKitPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
var result: FlutterResult?
var tag: NFCTag?
var multipleTagMessage: String?
private var checkNDEF = true

public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "flutter_nfc_kit/method", binaryMessenger: registrar.messenger())
Expand Down Expand Up @@ -63,6 +64,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
} else {
let arguments = call.arguments as! [String: Any?]
let technologies = arguments["technologies"] as! Int
checkNDEF = arguments["checkNDEF"] as? Bool ?? true
// TODO: derive pollingOption from technology flags
var pollingOption: NFCTagReaderSession.PollingOption = []
if (technologies & 0x3) != 0 {
Expand All @@ -74,15 +76,20 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
if (technologies & 0x8) != 0 {
pollingOption.insert(.iso15693)
}
session = NFCTagReaderSession(pollingOption: pollingOption, delegate: self)
session = NFCTagReaderSession(pollingOption: pollingOption, delegate: self, queue: DispatchQueue.main)
if let alertMessage = arguments["iosAlertMessage"] as? String {
session?.alertMessage = alertMessage
}
if let multipleTagMessage = arguments["iosMultipleTagMessage"] as? String {
self.multipleTagMessage = multipleTagMessage
}
self.result = result
session?.begin()
guard let session = session else {
self.result = nil
result(FlutterError(code: "405", message: "Unable to create NFC session", details: nil))
return
}
session.begin()
}
} else if call.method == "transceive" {
if tag != nil {
Expand Down Expand Up @@ -394,8 +401,9 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
result(FlutterError(code: "406", message: "No tag polled", details: nil))
}
} else if call.method == "finish" {
self.result?(FlutterError(code: "406", message: "Session not active", details: nil))
let pending = self.result
self.result = nil
pending?(FlutterError(code: "406", message: "Session not active", details: nil))

if let session = session {
let arguments = call.arguments as! [String: Any?]
Expand Down Expand Up @@ -464,48 +472,51 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe

// from NFCTagReaderSessionDelegate
public func tagReaderSession(_ invalidatedSession: NFCTagReaderSession, didInvalidateWithError error: Error) {
// Core NFC calls this on its own queue, but `poll()` and `finish()` change
// `session` / `result` on the main thread. Hop to main so the identity check and
// the state it guards happen in one step, ordered against those method calls.
// Keep the identity check and reader-state cleanup together on main,
// ordered against `poll()` and `finish()` method calls.
DispatchQueue.main.async { [self] in
// `finish()` invalidates the session and clears `self.session` straight away,
// but iOS can deliver that session's invalidation after the next `poll()` has
// already started a new one. Only the current session may complete the pending
// result or reset state — otherwise the stale error reaches the new poll, and
// clearing `self.session` / `self.tag` orphans a session still on screen.
guard invalidatedSession === session else { return }
guard result != nil else { return }
let pending = result
result = nil
session = nil
tag = nil
// A successful poll has already consumed its result. The reader can
// still time out or be canceled, and must be released in that case.
guard let pending = pending 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))
pending(FlutterError(code: "409", message: "SessionCanceled", details: error.localizedDescription))
case NFCReaderError.Code.readerSessionInvalidationErrorSessionTimeout.rawValue:
result?(FlutterError(code: "408", message: "SessionTimeOut", details: error.localizedDescription))
pending(FlutterError(code: "408", message: "SessionTimeOut", details: error.localizedDescription))
default:
result?(FlutterError(code: "500", message: "Generic NFC Error", details: error.localizedDescription))
pending(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))
pending(FlutterError(code: "500", message: "Invalidate session with error", details: error.localizedDescription))
}

result = nil
session = nil
tag = nil
}
}

// from NFCTagReaderSessionDelegate
public func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
guard self.session === session, self.result != nil, !tags.isEmpty else { return }
if tags.count > 1 {
// Restart polling in 500ms
let retryInterval = DispatchTimeInterval.milliseconds(500)
if multipleTagMessage != nil {
session.alertMessage = multipleTagMessage!
}
DispatchQueue.global().asyncAfter(deadline: .now() + retryInterval) {
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) {
guard self.session === session, self.result != nil else { return }
session.restartPolling()
}
return
Expand Down Expand Up @@ -570,68 +581,90 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe
}

session.connect(to: firstTag) { (error: Error?) in
if let error = error {
self.result?(FlutterError(code: "500", message: "Error connecting to card", details: error.localizedDescription))
self.result = nil
return
}
self.tag = firstTag

var ndefTag: NFCNDEFTag?
switch self.tag {
case let .iso7816(tag):
ndefTag = tag
case let .miFare(tag):
ndefTag = tag
case let .feliCa(tag):
ndefTag = tag
case let .iso15693(tag):
ndefTag = tag
default:
ndefTag = nil
}

if ndefTag != nil {
ndefTag!.queryNDEFStatus() { (status: NFCNDEFStatus, capacity: Int, error: Error?) in
if error == nil {
if status != NFCNDEFStatus.notSupported {
result["ndefAvailable"] = true
}
if status == NFCNDEFStatus.readWrite {
result["ndefWritable"] = true
result["ndefCanMakeReadOnly"] = true
}
result["ndefCapacity"] = capacity
}
// ignore error, just return with ndef disabled
switch self.tag {
case let .feliCa(tag):
tag.polling(systemCode: tag.currentSystemCode, requestCode: .noRequest, timeSlot: .max16) { (pmm: Data, _: Data, error: Error?) in
if let error = error {
self.result?(FlutterError(code: "500", message: "Communication error on connect", details: error.localizedDescription))
self.result = nil
} else {
result["manufacturer"] = pmm.hexEncodedString()
DispatchQueue.main.async {
guard self.session === session, self.result != nil else { return }
if let error = error {
self.failPoll(session, error: FlutterError(code: "500", message: "Error connecting to card", details: error.localizedDescription))
return
}
self.tag = firstTag

let jsonData = try! JSONSerialization.data(withJSONObject: result)
let jsonString = String(data: jsonData, encoding: .utf8)
self.result?(jsonString)
self.result = nil
var ndefTag: NFCNDEFTag?
switch firstTag {
case let .iso7816(tag):
ndefTag = tag
case let .miFare(tag):
ndefTag = tag
case let .feliCa(tag):
ndefTag = tag
case let .iso15693(tag):
ndefTag = tag
default:
ndefTag = nil
}

guard self.checkNDEF, let ndefTag = ndefTag else {
self.completePoll(session, metadata: result)
return
}
ndefTag.queryNDEFStatus() { (status: NFCNDEFStatus, capacity: Int, error: Error?) in
DispatchQueue.main.async {
guard self.session === session, self.result != nil else { return }
if error == nil {
if status != NFCNDEFStatus.notSupported {
result["ndefAvailable"] = true
}
if status == NFCNDEFStatus.readWrite {
result["ndefWritable"] = true
result["ndefCanMakeReadOnly"] = true
}
result["ndefCapacity"] = capacity
}
default:
let jsonData = try! JSONSerialization.data(withJSONObject: result)
let jsonString = String(data: jsonData, encoding: .utf8)
self.result?(jsonString)
self.result = nil
// Ignore NDEF errors and return with NDEF metadata disabled.
self.completePoll(session, metadata: result)
}
}
} else {
let jsonData = try! JSONSerialization.data(withJSONObject: result)
let jsonString = String(data: jsonData, encoding: .utf8)
self.result?(jsonString)
self.result = nil
}
}
}

// Called on main, including completion handlers that update reader state.
private func completePoll(_ session: NFCTagReaderSession, metadata: [String: Any]) {
guard self.session === session, self.result != nil else { return }
if case let .feliCa(tag) = self.tag {
// PMm is tag metadata, not NDEF metadata. Preserve it even when the
// caller skips queryNDEFStatus.
tag.polling(systemCode: tag.currentSystemCode, requestCode: .noRequest, timeSlot: .max16) { (pmm: Data, _: Data, error: Error?) in
DispatchQueue.main.async {
guard self.session === session, self.result != nil else { return }
if let error = error {
self.failPoll(session, error: FlutterError(code: "500", message: "Communication error on connect", details: error.localizedDescription))
} else {
var metadata = metadata
metadata["manufacturer"] = pmm.hexEncodedString()
self.returnPoll(metadata)
}
}
}
} else {
returnPoll(metadata)
}
}

private func returnPoll(_ metadata: [String: Any]) {
let pending = result
result = nil
let jsonData = try! JSONSerialization.data(withJSONObject: metadata)
pending?(String(data: jsonData, encoding: .utf8))
}

private func failPoll(_ session: NFCTagReaderSession, error: FlutterError) {
guard self.session === session else { return }
let pending = result
result = nil
self.session = nil
tag = nil
session.invalidate()
pending?(error)
}
}
24 changes: 18 additions & 6 deletions lib/flutter_nfc_kit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,15 @@ class FlutterNfcKit {
/// On iOS, set [iosAlertMessage] to display a message when the session starts (to guide users to scan a tag),
/// and set [iosMultipleTagMessage] to display a message when multiple tags are found.
///
/// On Android, set [androidPlatformSound] to control whether to play sound when a tag is polled,
/// and set [androidCheckNDEF] to control whether check NDEF records on the tag.
/// [checkNDEF] controls automatic NDEF discovery on Android and iOS (default
/// true). Set it to false for raw-command applications that do not need the
/// initial NDEF check. It is ignored on Web. Skipping the check leaves NDEF
/// metadata at its defaults (false, zero or empty), even for NDEF-capable tags.
/// Raw [transceive] commands remain available.
///
/// On Android, [androidPlatformSound] controls whether to play sound when a tag
/// is polled. The deprecated [androidCheckNDEF] overrides [checkNDEF] on Android
/// only when explicitly provided; it has no effect on iOS.
/// Set [extraReaderPresenceCheckDelay] to control the interval Android uses
/// when checking whether a discovered tag is still present. The value is
/// Android-only and is expressed as a [Duration]. When omitted, Android's
Expand All @@ -341,8 +348,10 @@ class FlutterNfcKit {
/// On Web, all parameters are ignored except [timeout] and [probeWebUSBMagic].
/// If [probeWebUSBMagic] is set, the library will use the `PROBE` request to check whether the device supports our API (see [FlutterNfcKitWeb] for details).
///
/// Note: Sometimes NDEF check [leads to error](https://github.com/nfcim/flutter_nfc_kit/issues/11), and disabling it might help.
/// If disabled, you will not be able to use any NDEF-related methods in the current session.
/// Note: On Android, sometimes NDEF check [leads to error](https://github.com/nfcim/flutter_nfc_kit/issues/11), and disabling it might help.
/// Disabling NDEF discovery makes Android NDEF-related methods unavailable
/// in the current session. On iOS, [checkNDEF] only skips the initial query;
/// NDEF methods still contact the tag, but capability metadata is not populated.
///
/// **Android Reader Mode Flags** ([androidReaderModeFlags]):
///
Expand All @@ -369,7 +378,9 @@ class FlutterNfcKit {
static Future<NFCTag> poll({
Duration? timeout,
bool androidPlatformSound = true,
bool androidCheckNDEF = true,
bool checkNDEF = true,
@Deprecated('Use checkNDEF instead. This override only affects Android.')
bool? androidCheckNDEF,
int? androidReaderModeFlags,
String iosAlertMessage = "Hold your iPhone near the card",
String iosMultipleTagMessage =
Expand All @@ -389,11 +400,12 @@ class FlutterNfcKit {
if (readIso18092) technologies |= 0x4;
if (readIso15693) technologies |= 0x8;
// iOS can safely ignore these option bits
if (!androidCheckNDEF) technologies |= 0x80;
if (!(androidCheckNDEF ?? checkNDEF)) technologies |= 0x80;
if (!androidPlatformSound) technologies |= 0x100;
final String data = await _channel.invokeMethod('poll', {
'timeout': timeout?.inMilliseconds ?? POLL_TIMEOUT,
'iosAlertMessage': iosAlertMessage,
'checkNDEF': checkNDEF,
'iosMultipleTagMessage': iosMultipleTagMessage,
'technologies': technologies,
'probeWebUSBMagic': probeWebUSBMagic,
Expand Down
Loading
Loading