diff --git a/CONTEXT.md b/CONTEXT.md index 07b96d6..426fd57 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,3 +27,7 @@ _Avoid_: response, raw response **APDU Status**: The two-byte status word returned at the end of an APDU response, represented as a four-character Hex String such as `9000`. _Avoid_: status code, result code + +**Card Presence Event**: +The public event emitted when a connected Reader reports that a card is present. +_Avoid_: card detection, tap event diff --git a/README.md b/README.md index aa1b1f2..1713575 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Typical integration order: 1. **Permissions** — check and request Reader permission before scanning. 2. **Scan** — discover nearby Readers for a bounded window. 3. **Connect** — connect one Reader at a time. -4. **Card** — listen for card presence, read UIDs, transmit APDUs, or use MIFARE Classic helpers. +4. **Card** — start monitoring when you need card presence events, read UIDs, transmit APDUs, or use MIFARE Classic helpers. ### Reader permissions @@ -83,10 +83,33 @@ Starting a new `scanReaders()` while one is active supersedes the prior scan: th After connecting a Reader: -- `addCardPresentListener()` / `addCardRemovedListener()` — card presence events. +- `addCardPresentListener()` / `addCardRemovedListener()` — subscribe to card presence events. +- `startCardMonitor(readerId, options?)` — explicitly start Card Presence Event monitoring. Connecting a Reader no longer starts monitoring by default in v0.2.0. +- `stopCardMonitor(readerId)` — stop monitoring silently. A manual stop or auto-stop does not mean the card was physically removed and does not emit a card removal event. - `readCardUid(readerId)` — card UID as a Hex String. - `transmit(readerId, apdu)` — send a raw APDU Hex String; resolves with `responseData` (APDU Response Data) and `status` (APDU Status). Non-`9000` statuses are returned, not thrown. +`startCardMonitor()` defaults to `{ pollingIntervalMs: 1000, autoStopAfterMs: null }`. `pollingIntervalMs` must be an integer of at least `100`. `autoStopAfterMs`, when provided, must be a positive integer. Repeated starts with the same options are idempotent; repeated starts with different options reject with `CARD_MONITOR_ALREADY_ACTIVE`. + +Card commands do not require Card Presence Event monitoring. For example, `readCardUid(readerId)` works after `connectReader(readerId)` even when no monitor is running. + +```ts +const present = addCardPresentListener(({ readerId }) => { + console.log('Card present', readerId); +}); +const removed = addCardRemovedListener(({ readerId }) => { + console.log('Card removed', readerId); +}); + +const reader = await connectReader(readerId); +await startCardMonitor(reader.id, { pollingIntervalMs: 1000, autoStopAfterMs: 30000 }); +await stopCardMonitor(reader.id); +await disconnectReader(reader.id); + +present.remove(); +removed.remove(); +``` + ### MIFARE Classic Use `mifare.authenticateBlock({ readerId, block, keyType, key })` with a caller-owned key before `mifare.readBlock({ readerId, block })` or `mifare.writeBlock({ readerId, block, data })`. @@ -105,11 +128,15 @@ Run these checks on real hardware when changing Reader, card, or MIFARE behavior 2. Start a 5 second scan; confirm an ACS BLE Reader appears. 3. Confirm scanning stops at timeout and after `stopReaderScan()`. 4. Connect the discovered Reader; confirm optional metadata when available. -5. Present and remove a card; confirm presence events update. -6. Read the card UID. -7. Transmit `FFCA000000`; confirm APDU Response Data and APDU Status are shown separately. -8. Authenticate a MIFARE Classic block with a caller-owned key, read the block, write 16 bytes, read it back. -9. Disconnect; confirm a second connect works only after disconnect. +5. Confirm no Card Presence Events arrive after `connectReader()` alone. +6. Start Card Presence Event monitoring; present and remove a card; confirm presence events update. +7. Start monitoring while a card is already present; confirm an immediate Card Presence Event. +8. Stop monitoring; confirm stop is silent and later card changes do not emit events. +9. Start monitoring with `autoStopAfterMs`; confirm auto-stop is silent. +10. Read the card UID without monitoring running. +11. Transmit `FFCA000000`; confirm APDU Response Data and APDU Status are shown separately. +12. Authenticate a MIFARE Classic block with a caller-owned key, read the block, write 16 bytes, read it back. +13. Disconnect; confirm a second connect works only after disconnect. **iOS** @@ -117,11 +144,15 @@ Run these checks on real hardware when changing Reader, card, or MIFARE behavior 2. Start a 5 second scan; confirm an ACS BLE Reader appears. 3. Confirm scanning stops at timeout and after `stopReaderScan()`. 4. Connect the discovered Reader; confirm optional metadata when available. -5. Present and remove a card; confirm presence events update. -6. Read the card UID. -7. Transmit `FFCA000000`; confirm APDU Response Data and APDU Status are shown separately. -8. Authenticate a MIFARE Classic block with a caller-owned key, read the block, write 16 bytes, read it back. -9. Disconnect; confirm a second connect works only after disconnect. +5. Confirm no Card Presence Events arrive after `connectReader()` alone. +6. Start Card Presence Event monitoring; present and remove a card; confirm presence events update. +7. Start monitoring while a card is already present; confirm an immediate Card Presence Event. +8. Stop monitoring; confirm stop is silent and later card changes do not emit events. +9. Start monitoring with `autoStopAfterMs`; confirm auto-stop is silent. +10. Read the card UID without monitoring running. +11. Transmit `FFCA000000`; confirm APDU Response Data and APDU Status are shown separately. +12. Authenticate a MIFARE Classic block with a caller-owned key, read the block, write 16 bytes, read it back. +13. Disconnect; confirm a second connect works only after disconnect. ## ACS SDK diff --git a/android/src/main/java/expo/modules/blenfcreader/ReactNativeBleNfcReaderModule.kt b/android/src/main/java/expo/modules/blenfcreader/ReactNativeBleNfcReaderModule.kt index 28e1517..970ef65 100644 --- a/android/src/main/java/expo/modules/blenfcreader/ReactNativeBleNfcReaderModule.kt +++ b/android/src/main/java/expo/modules/blenfcreader/ReactNativeBleNfcReaderModule.kt @@ -26,6 +26,8 @@ private const val READER_DISCOVERED_EVENT = "onReaderDiscovered" private const val CARD_PRESENT_EVENT = "onCardPresent" private const val CARD_REMOVED_EVENT = "onCardRemoved" private const val DEFAULT_SCAN_TIMEOUT_MS = 5000.0 +private const val DEFAULT_CARD_MONITOR_POLLING_INTERVAL_MS = 1000.0 +private const val MIN_CARD_MONITOR_POLLING_INTERVAL_MS = 100.0 private const val METADATA_TIMEOUT_MS = 1000L private const val READ_UID_APDU = "FFCA000000" private const val MIFARE_KEY_SLOT = "00" @@ -49,7 +51,10 @@ class ReactNativeBleNfcReaderModule : Module() { private var activeReaderManager: BluetoothTerminalManager? = null private var activeCard: Card? = null private var cardMonitorThread: Thread? = null + private var cardMonitorAutoStopRunnable: Runnable? = null private var cardMonitorGeneration = 0 + private var activeCardMonitorReaderId: String? = null + private var activeCardMonitorOptions: NormalizedCardMonitorOptions? = null private var scanPromise: Promise? = null private var scanStopRunnable: Runnable? = null private var scanTypeRunnable: Runnable? = null @@ -113,6 +118,32 @@ class ReactNativeBleNfcReaderModule : Module() { } } + AsyncFunction("startCardMonitor") { readerId: String, options: CardMonitorOptions?, promise: Promise -> + scanHandler.post { + try { + startCardMonitor(readerId, options) + promise.resolve(null) + } catch (error: CodedException) { + promise.reject(error) + } catch (_: Exception) { + promise.reject(ReaderConnectionUnavailableException()) + } + } + } + + AsyncFunction("stopCardMonitor") { readerId: String, promise: Promise -> + scanHandler.post { + try { + stopCardMonitor(readerId) + promise.resolve(null) + } catch (error: CodedException) { + promise.reject(error) + } catch (_: Exception) { + promise.reject(ReaderConnectionUnavailableException()) + } + } + } + AsyncFunction("readCardUid") { readerId: String, promise: Promise -> try { promise.resolve(readCardUid(readerId)) @@ -321,6 +352,33 @@ class ReactNativeBleNfcReaderModule : Module() { return timeoutMs.toLong() } + private fun normalizeCardMonitorOptions(options: CardMonitorOptions?): NormalizedCardMonitorOptions { + val pollingIntervalMs = options?.pollingIntervalMs ?: DEFAULT_CARD_MONITOR_POLLING_INTERVAL_MS + val autoStopAfterMs = options?.autoStopAfterMs + + if ( + !pollingIntervalMs.isFinite() || + pollingIntervalMs % 1.0 != 0.0 || + pollingIntervalMs < MIN_CARD_MONITOR_POLLING_INTERVAL_MS + ) { + throw InvalidCardMonitorOptionsException( + "pollingIntervalMs must be an integer greater than or equal to ${MIN_CARD_MONITOR_POLLING_INTERVAL_MS.toInt()}" + ) + } + + if ( + autoStopAfterMs != null && + (!autoStopAfterMs.isFinite() || autoStopAfterMs % 1.0 != 0.0 || autoStopAfterMs <= 0) + ) { + throw InvalidCardMonitorOptionsException("autoStopAfterMs must be a positive integer") + } + + return NormalizedCardMonitorOptions( + pollingIntervalMs = pollingIntervalMs.toLong(), + autoStopAfterMs = autoStopAfterMs?.toLong() + ) + } + private fun startCurrentScanType() { if (scanPromise == null) { return @@ -366,7 +424,7 @@ class ReactNativeBleNfcReaderModule : Module() { val manager = readerManager() finishScan() - val (terminal, shouldStartCardMonitor) = synchronized(readerLock) { + val terminal = synchronized(readerLock) { val activeId = activeReaderId if (activeId != null && activeId != readerId) { @@ -375,21 +433,17 @@ class ReactNativeBleNfcReaderModule : Module() { if (activeId == readerId) { val activeTerminal = activeReaderTerminal ?: throw ReaderNotConnectedException(readerId) - return@synchronized Pair(activeTerminal, false) + return@synchronized activeTerminal } val knownTerminal = knownTerminals[readerId] ?: throw ReaderNotFoundException(readerId) activeReaderId = readerId activeReaderTerminal = knownTerminal activeReaderManager = manager - Pair(knownTerminal, true) + knownTerminal } - val reader = readerForTerminal(terminal, manager) - if (shouldStartCardMonitor) { - startCardMonitor(readerId, terminal) - } - return reader + return readerForTerminal(terminal, manager) } private fun disconnectReader(readerId: String) { @@ -408,7 +462,7 @@ class ReactNativeBleNfcReaderModule : Module() { activeConnection.first.disconnect(activeConnection.second) } - stopCardMonitor() + stopCardMonitorInternal() synchronized(readerLock) { activeReaderId = null activeReaderTerminal = null @@ -504,58 +558,127 @@ class ReactNativeBleNfcReaderModule : Module() { } } - private fun startCardMonitor(readerId: String, terminal: CardTerminal) { - stopCardMonitor() + private fun startCardMonitor(readerId: String, options: CardMonitorOptions?) { + val normalizedOptions = normalizeCardMonitorOptions(options) + val terminal = synchronized(readerLock) { + if (activeReaderId != readerId) { + throw ReaderNotConnectedException(readerId) + } + + if (cardMonitorThread != null) { + if (activeCardMonitorReaderId == readerId && activeCardMonitorOptions == normalizedOptions) { + return + } + + throw CardMonitorAlreadyActiveException() + } + + activeReaderTerminal ?: throw ReaderNotConnectedException(readerId) + } val generation = synchronized(readerLock) { cardMonitorGeneration += 1 cardMonitorGeneration } val thread = Thread { - var wasPresent: Boolean? = null - - while (!Thread.currentThread().isInterrupted && isCurrentCardMonitor(generation)) { - val present = cardPresent(terminal) - - if (present == null) { - sleepCardMonitor() - continue - } + try { + var wasPresent: Boolean? = null - if (present && wasPresent != true) { - sendCardEvent(CARD_PRESENT_EVENT, readerId, generation) - } + while (!Thread.currentThread().isInterrupted && isCurrentCardMonitor(generation)) { + val present = cardPresent(terminal) - if (!present && wasPresent == true) { - if (!cardStillAbsent(terminal)) { + if (present == null) { + sleepCardMonitor(normalizedOptions.pollingIntervalMs) continue } - synchronized(terminalIoLock) { - disconnectActiveCardLocked() + if (present && wasPresent != true) { + sendCardEvent(CARD_PRESENT_EVENT, readerId, generation) + } + + if (!present && wasPresent == true) { + if (!cardStillAbsent(terminal)) { + continue + } + + synchronized(terminalIoLock) { + disconnectActiveCardLocked() + } + sendCardEvent(CARD_REMOVED_EVENT, readerId, generation) } - sendCardEvent(CARD_REMOVED_EVENT, readerId, generation) - } - wasPresent = present + wasPresent = present - if (!waitForCardChange(terminal, present)) { - break + if (!waitForCardChange(terminal, present, normalizedOptions.pollingIntervalMs)) { + break + } } + } finally { + clearCardMonitorIfCurrent(generation) } } synchronized(readerLock) { cardMonitorThread = thread + activeCardMonitorReaderId = readerId + activeCardMonitorOptions = normalizedOptions + } + normalizedOptions.autoStopAfterMs?.let { autoStopAfterMs -> + val autoStopRunnable = Runnable { + stopCardMonitorIfCurrent(generation) + } + synchronized(readerLock) { + cardMonitorAutoStopRunnable = autoStopRunnable + } + scanHandler.postDelayed(autoStopRunnable, autoStopAfterMs) } thread.start() } - private fun stopCardMonitor() { + private fun stopCardMonitor(readerId: String) { + synchronized(readerLock) { + if (activeReaderId != readerId) { + throw ReaderNotConnectedException(readerId) + } + } + + stopCardMonitorInternal() + } + + private fun stopCardMonitorIfCurrent(generation: Int) { + val shouldStop = synchronized(readerLock) { + cardMonitorGeneration == generation + } + + if (shouldStop) { + stopCardMonitorInternal() + } + } + + private fun clearCardMonitorIfCurrent(generation: Int) { + synchronized(readerLock) { + if (cardMonitorGeneration != generation || cardMonitorThread != Thread.currentThread()) { + return + } + + cardMonitorGeneration += 1 + cardMonitorAutoStopRunnable?.let(scanHandler::removeCallbacks) + cardMonitorAutoStopRunnable = null + cardMonitorThread = null + activeCardMonitorReaderId = null + activeCardMonitorOptions = null + } + } + + private fun stopCardMonitorInternal() { val thread = synchronized(readerLock) { cardMonitorGeneration += 1 + cardMonitorAutoStopRunnable?.let(scanHandler::removeCallbacks) + cardMonitorAutoStopRunnable = null val currentThread = cardMonitorThread cardMonitorThread = null + activeCardMonitorReaderId = null + activeCardMonitorOptions = null currentThread } thread?.interrupt() @@ -592,13 +715,13 @@ class ReactNativeBleNfcReaderModule : Module() { return cardPresent(terminal) != true } - private fun waitForCardChange(terminal: CardTerminal, present: Boolean): Boolean { + private fun waitForCardChange(terminal: CardTerminal, present: Boolean, pollingIntervalMs: Long): Boolean { return synchronized(terminalIoLock) { try { if (present) { - terminal.waitForCardAbsent(1000) + terminal.waitForCardAbsent(pollingIntervalMs) } else { - terminal.waitForCardPresent(1000) + terminal.waitForCardPresent(pollingIntervalMs) } true } catch (error: CardException) { @@ -607,7 +730,7 @@ class ReactNativeBleNfcReaderModule : Module() { } } - private fun sleepCardMonitor(delayMs: Long = 500) { + private fun sleepCardMonitor(delayMs: Long) { try { Thread.sleep(delayMs) } catch (_: InterruptedException) { @@ -790,6 +913,19 @@ private class ScanReadersOptions : Record { var timeoutMs: Double? = null } +private class CardMonitorOptions : Record { + @Field + var pollingIntervalMs: Double? = null + + @Field + var autoStopAfterMs: Double? = null +} + +private data class NormalizedCardMonitorOptions( + val pollingIntervalMs: Long, + val autoStopAfterMs: Long? +) + private class AuthenticateBlockOptions : Record { @Field var readerId: String = "" @@ -850,6 +986,12 @@ private class InvalidScanTimeoutException : CodedException( null ) +private class InvalidCardMonitorOptionsException(message: String) : CodedException( + "INVALID_CARD_MONITOR_OPTIONS", + message, + null +) + private class InvalidHexStringException(message: String = "apdu must contain only hex characters and have an even number of characters") : CodedException( "INVALID_HEX_STRING", message, @@ -898,6 +1040,12 @@ private class ReaderConnectionUnavailableException : CodedException( null ) +private class CardMonitorAlreadyActiveException : CodedException( + "CARD_MONITOR_ALREADY_ACTIVE", + "Card monitor is already active with different options", + null +) + private class CardCommandFailedException(status: String) : CodedException( "CARD_COMMAND_FAILED", "Card command failed with APDU Status $status", diff --git a/docs/adr/0026-use-explicit-card-monitoring-by-default.md b/docs/adr/0026-use-explicit-card-monitoring-by-default.md new file mode 100644 index 0000000..db992e4 --- /dev/null +++ b/docs/adr/0026-use-explicit-card-monitoring-by-default.md @@ -0,0 +1,3 @@ +# Use Explicit Card Monitoring By Default + +The v0.2 API does not start Card Presence Event monitoring automatically when a Reader connects. Apps call `startCardMonitor()` when they need presence/removal events, while card commands such as `readCardUid()` continue to work without the monitor; this favors battery life over implicit always-on event monitoring. diff --git a/ios/ReactNativeBleNfcReaderModule.swift b/ios/ReactNativeBleNfcReaderModule.swift index 487ae6c..7ed1de4 100644 --- a/ios/ReactNativeBleNfcReaderModule.swift +++ b/ios/ReactNativeBleNfcReaderModule.swift @@ -8,6 +8,8 @@ private let readerDiscoveredEvent = "onReaderDiscovered" private let cardPresentEvent = "onCardPresent" private let cardRemovedEvent = "onCardRemoved" private let defaultScanTimeoutMs = 5000.0 +private let defaultCardMonitorPollingIntervalMs = 1000.0 +private let minCardMonitorPollingIntervalMs = 100.0 private let metadataTimeoutMs = 1000 private let readUidApdu = "FFCA000000" private let mifareKeySlot = "00" @@ -35,7 +37,10 @@ public class ReactNativeBleNfcReaderModule: Module, BluetoothTerminalManagerDele private var activeReader: (id: String, terminal: any CardTerminal)? private var activeCard: (any Card)? private var cardMonitorThread: Thread? + private var cardMonitorAutoStopTimer: Timer? private var cardMonitorGeneration = 0 + private var activeCardMonitorReaderId: String? + private var activeCardMonitorOptions: NormalizedCardMonitorOptions? private let readerStateLock = NSLock() private let terminalIoLock = NSLock() @@ -100,6 +105,30 @@ public class ReactNativeBleNfcReaderModule: Module, BluetoothTerminalManagerDele } .runOnQueue(.main) + AsyncFunction("startCardMonitor") { (readerId: String, options: CardMonitorOptions?, promise: Promise) in + do { + try self.startCardMonitor(readerId: readerId, options: options) + promise.resolve(nil) + } catch let error as Exception { + promise.reject(error) + } catch { + promise.reject(readerConnectionUnavailableException()) + } + } + .runOnQueue(.main) + + AsyncFunction("stopCardMonitor") { (readerId: String, promise: Promise) in + do { + try self.stopCardMonitor(readerId: readerId) + promise.resolve(nil) + } catch let error as Exception { + promise.reject(error) + } catch { + promise.reject(readerConnectionUnavailableException()) + } + } + .runOnQueue(.main) + AsyncFunction("readCardUid") { (readerId: String, promise: Promise) in do { promise.resolve(try self.readCardUid(readerId: readerId)) @@ -308,7 +337,6 @@ public class ReactNativeBleNfcReaderModule: Module, BluetoothTerminalManagerDele activeReader = (readerId, terminal) } let reader = readerForTerminal(terminal, includeMetadata: true) - startCardMonitor(readerId: readerId, terminal: terminal) return reader } @@ -325,7 +353,7 @@ public class ReactNativeBleNfcReaderModule: Module, BluetoothTerminalManagerDele disconnectActiveCard() try scanManager.disconnect(terminal: terminal) } - stopCardMonitor() + stopCardMonitorInternal() withReaderStateLock { if activeReader?.id == readerId { activeReader = nil @@ -416,28 +444,84 @@ public class ReactNativeBleNfcReaderModule: Module, BluetoothTerminalManagerDele } } - private func startCardMonitor(readerId: String, terminal: any CardTerminal) { - stopCardMonitor() + private func startCardMonitor(readerId: String, options: CardMonitorOptions?) throws { + let normalizedOptions = try normalizeCardMonitorOptions(options) + let terminal = try withReaderStateLock { + guard let activeReader, activeReader.id == readerId else { + throw readerNotConnectedException(readerId: readerId) + } + + if cardMonitorThread != nil { + if activeCardMonitorReaderId == readerId && activeCardMonitorOptions == normalizedOptions { + return nil + } + + throw cardMonitorAlreadyActiveException() + } + + return activeReader.terminal + } + + guard let terminal else { + return + } let generation = withReaderStateLock { cardMonitorGeneration += 1 return cardMonitorGeneration } let thread = Thread { [weak self] in - self?.monitorCardState(readerId: readerId, terminal: terminal, generation: generation) + self?.monitorCardState( + readerId: readerId, + terminal: terminal, + generation: generation, + pollingIntervalMs: normalizedOptions.pollingIntervalMs + ) } withReaderStateLock { cardMonitorThread = thread + activeCardMonitorReaderId = readerId + activeCardMonitorOptions = normalizedOptions + } + if let autoStopAfterMs = normalizedOptions.autoStopAfterMs { + cardMonitorAutoStopTimer = Timer.scheduledTimer(withTimeInterval: Double(autoStopAfterMs) / 1000.0, repeats: false) { + [weak self] _ in + self?.stopCardMonitorIfCurrent(generation: generation) + } } thread.start() } - private func stopCardMonitor() { + private func stopCardMonitor(readerId: String) throws { + try withReaderStateLock { + guard activeReader?.id == readerId else { + throw readerNotConnectedException(readerId: readerId) + } + } + + stopCardMonitorInternal() + } + + private func stopCardMonitorIfCurrent(generation: Int) { + let shouldStop = withReaderStateLock { + cardMonitorGeneration == generation + } + + if shouldStop { + stopCardMonitorInternal() + } + } + + private func stopCardMonitorInternal() { let thread = withReaderStateLock { cardMonitorGeneration += 1 + cardMonitorAutoStopTimer?.invalidate() + cardMonitorAutoStopTimer = nil let thread = cardMonitorThread cardMonitorThread = nil + activeCardMonitorReaderId = nil + activeCardMonitorOptions = nil return thread } thread?.cancel() @@ -452,14 +536,23 @@ public class ReactNativeBleNfcReaderModule: Module, BluetoothTerminalManagerDele } } - private func monitorCardState(readerId: String, terminal: any CardTerminal, generation: Int) { + private func monitorCardState( + readerId: String, + terminal: any CardTerminal, + generation: Int, + pollingIntervalMs: Int + ) { + defer { + clearCardMonitorIfCurrent(generation) + } + var wasPresent: Bool? while !Thread.current.isCancelled && isCurrentCardMonitor(generation) { let present = cardPresent(terminal) guard let present else { - Thread.sleep(forTimeInterval: 0.5) + Thread.sleep(forTimeInterval: Double(pollingIntervalMs) / 1000.0) continue } @@ -480,12 +573,27 @@ public class ReactNativeBleNfcReaderModule: Module, BluetoothTerminalManagerDele wasPresent = present - if !waitForCardChange(terminal, present: present) { + if !waitForCardChange(terminal, present: present, pollingIntervalMs: pollingIntervalMs) { break } } } + private func clearCardMonitorIfCurrent(_ generation: Int) { + withReaderStateLock { + if cardMonitorGeneration != generation || cardMonitorThread !== Thread.current { + return + } + + cardMonitorGeneration += 1 + cardMonitorAutoStopTimer?.invalidate() + cardMonitorAutoStopTimer = nil + cardMonitorThread = nil + activeCardMonitorReaderId = nil + activeCardMonitorOptions = nil + } + } + private func isCurrentCardMonitor(_ generation: Int) -> Bool { return withReaderStateLock { cardMonitorGeneration == generation && cardMonitorThread === Thread.current @@ -507,13 +615,17 @@ public class ReactNativeBleNfcReaderModule: Module, BluetoothTerminalManagerDele return cardPresent(terminal) != true } - private func waitForCardChange(_ terminal: any CardTerminal, present: Bool) -> Bool { + private func waitForCardChange( + _ terminal: any CardTerminal, + present: Bool, + pollingIntervalMs: Int + ) -> Bool { do { try withTerminalLock { if present { - _ = try terminal.waitForCardAbsent(timeout: 1000) + _ = try terminal.waitForCardAbsent(timeout: pollingIntervalMs) } else { - _ = try terminal.waitForCardPresent(timeout: 1000) + _ = try terminal.waitForCardPresent(timeout: pollingIntervalMs) } } return true @@ -558,6 +670,29 @@ public class ReactNativeBleNfcReaderModule: Module, BluetoothTerminalManagerDele } } + private func normalizeCardMonitorOptions(_ options: CardMonitorOptions?) throws -> NormalizedCardMonitorOptions { + let pollingIntervalMs = options?.pollingIntervalMs ?? defaultCardMonitorPollingIntervalMs + let autoStopAfterMs = options?.autoStopAfterMs + + if !pollingIntervalMs.isFinite || + pollingIntervalMs.rounded() != pollingIntervalMs || + pollingIntervalMs < minCardMonitorPollingIntervalMs { + throw invalidCardMonitorOptionsException( + description: "pollingIntervalMs must be an integer greater than or equal to \(Int(minCardMonitorPollingIntervalMs))" + ) + } + + if let autoStopAfterMs, + (!autoStopAfterMs.isFinite || autoStopAfterMs.rounded() != autoStopAfterMs || autoStopAfterMs <= 0) { + throw invalidCardMonitorOptionsException(description: "autoStopAfterMs must be a positive integer") + } + + return NormalizedCardMonitorOptions( + pollingIntervalMs: Int(pollingIntervalMs), + autoStopAfterMs: autoStopAfterMs.map { Int($0) } + ) + } + private func normalizeMifareBlock(_ block: Int) throws -> Int { if block < 0 || block > 255 { throw invalidMifareBlockException(description: "block must be an integer between 0 and 255") @@ -693,6 +828,19 @@ private struct ScanReadersOptions: Record { var timeoutMs: Double? } +private struct CardMonitorOptions: Record { + @Field + var pollingIntervalMs: Double? + + @Field + var autoStopAfterMs: Double? +} + +private struct NormalizedCardMonitorOptions: Equatable { + let pollingIntervalMs: Int + let autoStopAfterMs: Int? +} + private struct AuthenticateBlockOptions: Record { @Field var readerId: String = "" @@ -753,6 +901,14 @@ private func invalidScanTimeoutException() -> Exception { ) } +private func invalidCardMonitorOptionsException(description: String) -> Exception { + return Exception( + name: "InvalidCardMonitorOptionsException", + description: description, + code: "INVALID_CARD_MONITOR_OPTIONS" + ) +} + private func readerNotFoundException(readerId: String) -> Exception { return Exception( name: "ReaderNotFoundException", @@ -785,6 +941,14 @@ private func readerConnectionUnavailableException() -> Exception { ) } +private func cardMonitorAlreadyActiveException() -> Exception { + return Exception( + name: "CardMonitorAlreadyActiveException", + description: "Card monitor is already active with different options", + code: "CARD_MONITOR_ALREADY_ACTIVE" + ) +} + private func invalidHexStringException(description: String = "apdu must contain only hex characters") -> Exception { return Exception( name: "InvalidHexStringException", diff --git a/src/ReactNativeBleNfcReader.test.ts b/src/ReactNativeBleNfcReader.test.ts index 90d11b5..d887968 100644 --- a/src/ReactNativeBleNfcReader.test.ts +++ b/src/ReactNativeBleNfcReader.test.ts @@ -1,4 +1,6 @@ import { + addCardPresentListener, + addCardRemovedListener, addReaderDiscoveredListener, connectReader, disconnectReader, @@ -9,10 +11,10 @@ import { requestReaderPermissions, scanReaders, splitApduResponse, + startCardMonitor, stopReaderScan, + stopCardMonitor, transmit, - addCardPresentListener, - addCardRemovedListener, } from './ReactNativeBleNfcReader'; import { BleNfcReaderError } from './ReactNativeBleNfcReader.types'; import webModule from './ReactNativeBleNfcReaderModule.web'; @@ -319,6 +321,69 @@ describe('public native wrappers', () => { await expect(stopReaderScan()).resolves.toEqual([{ id: 'reader-1' }]); }); + it('starts and stops card monitoring through native calls', async () => { + mockNativeModule.startCardMonitor = jest.fn(async () => undefined); + mockNativeModule.stopCardMonitor = jest.fn(async () => undefined); + + await expect(startCardMonitor('reader-1')).resolves.toBeUndefined(); + await expect( + startCardMonitor('reader-1', { pollingIntervalMs: 250, autoStopAfterMs: 1000 }) + ).resolves.toBeUndefined(); + await expect(stopCardMonitor('reader-1')).resolves.toBeUndefined(); + + expect(mockNativeModule.startCardMonitor).toHaveBeenNthCalledWith(1, 'reader-1', { + pollingIntervalMs: 1000, + autoStopAfterMs: null, + }); + expect(mockNativeModule.startCardMonitor).toHaveBeenNthCalledWith(2, 'reader-1', { + pollingIntervalMs: 250, + autoStopAfterMs: 1000, + }); + expect(mockNativeModule.stopCardMonitor).toHaveBeenCalledWith('reader-1'); + }); + + it('rejects invalid card monitor options before native calls', async () => { + mockNativeModule.startCardMonitor = jest.fn(async () => undefined); + + await expect(startCardMonitor('reader-1', { pollingIntervalMs: 99 })).rejects.toEqual( + expect.objectContaining({ + code: 'INVALID_CARD_MONITOR_OPTIONS', + }) + ); + await expect(startCardMonitor('reader-1', { pollingIntervalMs: Number.NaN })).rejects.toEqual( + expect.objectContaining({ + code: 'INVALID_CARD_MONITOR_OPTIONS', + }) + ); + await expect(startCardMonitor('reader-1', { pollingIntervalMs: 100.5 })).rejects.toEqual( + expect.objectContaining({ + code: 'INVALID_CARD_MONITOR_OPTIONS', + }) + ); + await expect(startCardMonitor('reader-1', { autoStopAfterMs: 0 })).rejects.toEqual( + expect.objectContaining({ + code: 'INVALID_CARD_MONITOR_OPTIONS', + }) + ); + + expect(mockNativeModule.startCardMonitor).not.toHaveBeenCalled(); + }); + + it('maps active card monitor conflicts to typed errors', async () => { + mockNativeModule.startCardMonitor = jest.fn(async () => { + const error = new Error('Card monitor is already active with different options'); + Object.assign(error, { code: 'CARD_MONITOR_ALREADY_ACTIVE' }); + throw error; + }); + + await expect(startCardMonitor('reader-1')).rejects.toEqual( + expect.objectContaining({ + code: 'CARD_MONITOR_ALREADY_ACTIVE', + }) + ); + await expect(startCardMonitor('reader-1')).rejects.toBeInstanceOf(BleNfcReaderError); + }); + it('connects and disconnects Readers by Reader ID', async () => { mockNativeModule.connectReader = jest.fn(async () => ({ id: 'reader-1', @@ -504,5 +569,11 @@ describe('web module', () => { await expect(webModule.connectReader('reader-1')).rejects.toEqual( expect.objectContaining({ code: 'UNSUPPORTED_PLATFORM' }) ); + await expect(webModule.startCardMonitor('reader-1')).rejects.toEqual( + expect.objectContaining({ code: 'UNSUPPORTED_PLATFORM' }) + ); + await expect(webModule.stopCardMonitor('reader-1')).rejects.toEqual( + expect.objectContaining({ code: 'UNSUPPORTED_PLATFORM' }) + ); }); }); diff --git a/src/ReactNativeBleNfcReader.ts b/src/ReactNativeBleNfcReader.ts index 896351c..551f595 100644 --- a/src/ReactNativeBleNfcReader.ts +++ b/src/ReactNativeBleNfcReader.ts @@ -4,6 +4,7 @@ import { ApduResponse, AuthenticateBlockOptions, BleNfcReaderError, + CardMonitorOptions, createUnsupportedPlatformError, HexString, Reader, @@ -105,6 +106,10 @@ function normalizeNativeError(error: unknown): unknown { return new BleNfcReaderError('INVALID_SCAN_TIMEOUT', getErrorMessage(error)); } + if (error.code === 'INVALID_CARD_MONITOR_OPTIONS') { + return new BleNfcReaderError('INVALID_CARD_MONITOR_OPTIONS', getErrorMessage(error)); + } + if (error.code === 'READER_SCAN_UNAVAILABLE') { return new BleNfcReaderError('READER_SCAN_UNAVAILABLE', getErrorMessage(error)); } @@ -125,6 +130,10 @@ function normalizeNativeError(error: unknown): unknown { return new BleNfcReaderError('READER_CONNECTION_UNAVAILABLE', getErrorMessage(error)); } + if (error.code === 'CARD_MONITOR_ALREADY_ACTIVE') { + return new BleNfcReaderError('CARD_MONITOR_ALREADY_ACTIVE', getErrorMessage(error)); + } + if (error.code === 'CARD_COMMAND_FAILED') { const message = getErrorMessage(error); return new BleNfcReaderError('CARD_COMMAND_FAILED', message, extractApduStatus(message)); @@ -217,6 +226,39 @@ function normalizeScanOptions(options?: ScanReadersOptions): ScanReadersOptions return options; } +function normalizeCardMonitorOptions(options?: CardMonitorOptions): Required { + const pollingIntervalMs = options?.pollingIntervalMs ?? 1000; + const autoStopAfterMs = options?.autoStopAfterMs ?? null; + + if ( + !Number.isFinite(pollingIntervalMs) || + !Number.isInteger(pollingIntervalMs) || + pollingIntervalMs < 100 + ) { + throw new BleNfcReaderError( + 'INVALID_CARD_MONITOR_OPTIONS', + 'pollingIntervalMs must be an integer greater than or equal to 100' + ); + } + + if ( + autoStopAfterMs !== null && + (!Number.isFinite(autoStopAfterMs) || + !Number.isInteger(autoStopAfterMs) || + autoStopAfterMs <= 0) + ) { + throw new BleNfcReaderError( + 'INVALID_CARD_MONITOR_OPTIONS', + 'autoStopAfterMs must be a positive integer' + ); + } + + return { + pollingIntervalMs, + autoStopAfterMs, + }; +} + export async function getReaderPermissionStatus(): Promise { return callNative(() => getNativeMethod('getReaderPermissionStatus')()); } @@ -275,6 +317,19 @@ export async function disconnectReader(readerId: ReaderId): Promise { return callNative(() => getNativeMethod('disconnectReader')(readerId)); } +export async function startCardMonitor( + readerId: ReaderId, + options?: CardMonitorOptions +): Promise { + return callNative(() => + getNativeMethod('startCardMonitor')(readerId, normalizeCardMonitorOptions(options)) + ); +} + +export async function stopCardMonitor(readerId: ReaderId): Promise { + return callNative(() => getNativeMethod('stopCardMonitor')(readerId)); +} + export async function readCardUid(readerId: ReaderId): Promise { const uid = await callNative(() => getNativeMethod('readCardUid')(readerId)); return normalizeHexString(uid, 'uid'); diff --git a/src/ReactNativeBleNfcReader.types.ts b/src/ReactNativeBleNfcReader.types.ts index 86d5a30..13c3f79 100644 --- a/src/ReactNativeBleNfcReader.types.ts +++ b/src/ReactNativeBleNfcReader.types.ts @@ -20,6 +20,11 @@ export type ScanReadersOptions = { timeoutMs?: number; }; +export type CardMonitorOptions = { + pollingIntervalMs?: number; + autoStopAfterMs?: number | null; +}; + export type ReaderDiscoveredEvent = { reader: Reader; }; @@ -62,6 +67,7 @@ export type BleNfcReaderErrorCode = | 'INVALID_HEX_STRING' | 'INVALID_APDU_RESPONSE' | 'INVALID_SCAN_TIMEOUT' + | 'INVALID_CARD_MONITOR_OPTIONS' | 'INVALID_MIFARE_BLOCK' | 'INVALID_MIFARE_KEY_TYPE' | 'NATIVE_METHOD_UNAVAILABLE' @@ -73,6 +79,7 @@ export type BleNfcReaderErrorCode = | 'READER_ALREADY_CONNECTED' | 'READER_NOT_CONNECTED' | 'READER_CONNECTION_UNAVAILABLE' + | 'CARD_MONITOR_ALREADY_ACTIVE' | 'CARD_COMMAND_FAILED' | 'UNSUPPORTED_PLATFORM'; diff --git a/src/ReactNativeBleNfcReaderModule.ts b/src/ReactNativeBleNfcReaderModule.ts index 919e569..e59a4e6 100644 --- a/src/ReactNativeBleNfcReaderModule.ts +++ b/src/ReactNativeBleNfcReaderModule.ts @@ -2,6 +2,7 @@ import { NativeModule, requireNativeModule } from 'expo'; import { AuthenticateBlockOptions, + CardMonitorOptions, HexString, Reader, ReaderCardEvent, @@ -26,6 +27,8 @@ export declare class ReactNativeBleNfcReaderModule extends NativeModule Promise; connectReader?: (readerId: ReaderId) => Promise; disconnectReader?: (readerId: ReaderId) => Promise; + startCardMonitor?: (readerId: ReaderId, options?: CardMonitorOptions) => Promise; + stopCardMonitor?: (readerId: ReaderId) => Promise; readCardUid?: (readerId: ReaderId) => Promise; transmit?: (readerId: ReaderId, apdu: HexString) => Promise; authenticateBlock?: (options: AuthenticateBlockOptions) => Promise; diff --git a/src/ReactNativeBleNfcReaderModule.web.ts b/src/ReactNativeBleNfcReaderModule.web.ts index 8eea862..8399e21 100644 --- a/src/ReactNativeBleNfcReaderModule.web.ts +++ b/src/ReactNativeBleNfcReaderModule.web.ts @@ -2,6 +2,7 @@ import { registerWebModule, NativeModule } from 'expo'; import { createUnsupportedPlatformError, + CardMonitorOptions, HexString, Reader, ReaderId, @@ -39,6 +40,14 @@ class ReactNativeBleNfcReaderModule extends NativeModule { return this.rejectUnsupported(); } + startCardMonitor(_readerId: ReaderId, _options?: CardMonitorOptions): Promise { + return this.rejectUnsupported(); + } + + stopCardMonitor(_readerId: ReaderId): Promise { + return this.rejectUnsupported(); + } + readCardUid(_readerId: ReaderId): Promise { return this.rejectUnsupported(); } diff --git a/src/index.ts b/src/index.ts index 5219818..ed23e76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,9 @@ export { requestReaderPermissions, scanReaders, splitApduResponse, + startCardMonitor, stopReaderScan, + stopCardMonitor, transmit, } from './ReactNativeBleNfcReader'; export * from './ReactNativeBleNfcReader.types';