diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopCardScanner.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopCardScanner.kt index a5824a2fa..6031eded3 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopCardScanner.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopCardScanner.kt @@ -71,49 +71,50 @@ class DesktopCardScanner : CardScanner { scanJob = scope.launch { - val backends = discoverBackends() - val backendJobs = - backends.map { backend -> - launch { - println("[DesktopCardScanner] Starting ${backend.name} backend") - try { - backend.scanLoop( - onCardDetected = { tag -> - _scannedTags.tryEmit(tag) - }, - onCardRead = { rawCard -> - _scannedCards.tryEmit(rawCard) - }, - onError = { error -> - _scanErrors.tryEmit(error) - }, - ) - } catch (e: Exception) { - if (isActive) { - println("[DesktopCardScanner] ${backend.name} backend failed: ${e.message}") + try { + val backends = discoverBackends() + val backendJobs = + backends.map { backend -> + launch { + println("[DesktopCardScanner] Starting ${backend.name} backend") + try { + backend.scanLoop( + onCardDetected = { tag -> + _scannedTags.tryEmit(tag) + }, + onCardRead = { rawCard -> + _scannedCards.tryEmit(rawCard) + }, + onError = { error -> + _scanErrors.tryEmit(error) + }, + ) + } catch (e: Exception) { + if (isActive) { + println("[DesktopCardScanner] ${backend.name} backend failed: ${e.message}") + } + } catch (e: Error) { + // Catch LinkageError / UnsatisfiedLinkError from native libs + println("[DesktopCardScanner] ${backend.name} backend unavailable: ${e.message}") } - } catch (e: Error) { - // Catch LinkageError / UnsatisfiedLinkError from native libs - println("[DesktopCardScanner] ${backend.name} backend unavailable: ${e.message}") } } - } - backendJobs.forEach { it.join() } + backendJobs.forEach { it.join() } - // All backends exited — emit error only if none ran successfully - if (isActive) { - _scanErrors.tryEmit(Exception("All NFC reader backends failed. Is a USB NFC reader connected?")) + // All backends exited — emit error only if none ran successfully + if (isActive) { + _scanErrors.tryEmit(Exception("All NFC reader backends failed. Is a USB NFC reader connected?")) + } + } finally { + _isScanning.value = false } - _isScanning.value = false } } override fun stopActiveScan() { scanJob?.cancel() scanJob = null - _isScanning.value = false - PN533Device.shutdown() } private suspend fun discoverBackends(): List { diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/NfcReaderBackend.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/NfcReaderBackend.kt index 4b5ca4a75..04605ce14 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/NfcReaderBackend.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/NfcReaderBackend.kt @@ -36,7 +36,7 @@ import com.codebutler.farebot.shared.nfc.ScannedTag interface NfcReaderBackend { val name: String - fun scanLoop( + suspend fun scanLoop( onCardDetected: (ScannedTag) -> Unit, onCardRead: (RawCard<*>) -> Unit, onError: (Throwable) -> Unit, diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt index 6b57056f8..9b273215c 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt @@ -41,7 +41,6 @@ import com.codebutler.farebot.card.ultralight.UltralightCardReader import com.codebutler.farebot.shared.nfc.ISO7816Dispatcher import com.codebutler.farebot.shared.nfc.ScannedTag import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking /** * Abstract base for PN53x-family USB reader backends. @@ -59,7 +58,7 @@ abstract class PN53xReaderBackend( tg: Int, ): CardTransceiver = PN533CardTransceiver(pn533, tg) - override fun scanLoop( + override suspend fun scanLoop( onCardDetected: (ScannedTag) -> Unit, onCardRead: (RawCard<*>) -> Unit, onError: (Throwable) -> Unit, @@ -72,10 +71,8 @@ abstract class PN53xReaderBackend( transport.flush() val pn533 = PN533(transport) try { - runBlocking { - initDevice(pn533) - pollLoop(pn533, onCardDetected, onCardRead, onError) - } + initDevice(pn533) + pollLoop(pn533, onCardDetected, onCardRead, onError) } finally { pn533.close() } diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt index 01ab52d36..07000aa47 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt @@ -37,7 +37,6 @@ import com.codebutler.farebot.card.ultralight.UltralightCardReader import com.codebutler.farebot.card.vicinity.VicinityCardReader import com.codebutler.farebot.shared.nfc.ISO7816Dispatcher import com.codebutler.farebot.shared.nfc.ScannedTag -import kotlinx.coroutines.runBlocking import javax.smartcardio.CardException import javax.smartcardio.CommandAPDU import javax.smartcardio.TerminalFactory @@ -51,7 +50,7 @@ import javax.smartcardio.TerminalFactory class PcscReaderBackend : NfcReaderBackend { override val name: String = "PC/SC" - override fun scanLoop( + override suspend fun scanLoop( onCardDetected: (ScannedTag) -> Unit, onCardRead: (RawCard<*>) -> Unit, onError: (Throwable) -> Unit, @@ -96,7 +95,7 @@ class PcscReaderBackend : NfcReaderBackend { println("[PC/SC] Tag ID: ${tagId.hex()}") onCardDetected(ScannedTag(id = tagId, techList = listOf(info.cardType.name))) - val rawCard = runBlocking { readCard(info, channel, tagId) } + val rawCard = readCard(info, channel, tagId) onCardRead(rawCard) println("[PC/SC] Card read successfully") } finally { diff --git a/app/ios/FareBot.xcodeproj/project.pbxproj b/app/ios/FareBot.xcodeproj/project.pbxproj index b45acf239..da7c07602 100644 --- a/app/ios/FareBot.xcodeproj/project.pbxproj +++ b/app/ios/FareBot.xcodeproj/project.pbxproj @@ -7,46 +7,18 @@ objects = { /* Begin PBXBuildFile section */ - 7BFF0BD60CC51FB78D8A764D /* FareBotKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E296318A4ABC8EE549B0C47E /* FareBotKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 8E11E423477F24B274729679 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 534508E7AAA01FF336ECDC0C /* iOSApp.swift */; }; D52C887B87D2D7CD2DF7A030 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 445C357A8AB1DD9317170556 /* Assets.xcassets */; }; - EA3AC0F2B800448FB22567C4 /* FareBotKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296318A4ABC8EE549B0C47E /* FareBotKit.framework */; }; /* End PBXBuildFile section */ -/* Begin PBXCopyFilesBuildPhase section */ - C396E052E1BD6239F169D5D4 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - 7BFF0BD60CC51FB78D8A764D /* FareBotKit.framework in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - /* Begin PBXFileReference section */ 154ABFFD520502DDADF58B61 /* FareBot.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FareBot.app; sourceTree = BUILT_PRODUCTS_DIR; }; 445C357A8AB1DD9317170556 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 534508E7AAA01FF336ECDC0C /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; A893E13DD60D0B10ECB49A59 /* FareBot.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FareBot.entitlements; sourceTree = ""; }; - E296318A4ABC8EE549B0C47E /* FareBotKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FareBotKit.framework; path = "../farebot-app/build/bin/iosSimulatorArm64/debugFramework/FareBotKit.framework"; sourceTree = ""; }; E65B641D90F72BA2E1DEAFF7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ -/* Begin PBXFrameworksBuildPhase section */ - E1F31206D4AE717D1E2DE8D8 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - EA3AC0F2B800448FB22567C4 /* FareBotKit.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - /* Begin PBXGroup section */ 2098F79B3F3B6A526575D03F /* Products */ = { isa = PBXGroup; @@ -67,19 +39,10 @@ path = FareBot; sourceTree = ""; }; - 9B0F3B4C26A1726B3C4A2BE9 /* Frameworks */ = { - isa = PBXGroup; - children = ( - E296318A4ABC8EE549B0C47E /* FareBotKit.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; E8645766090C58DFD719F43E = { isa = PBXGroup; children = ( 35C5B4B3C4B8B2643DF5E68A /* FareBot */, - 9B0F3B4C26A1726B3C4A2BE9 /* Frameworks */, 2098F79B3F3B6A526575D03F /* Products */, ); sourceTree = ""; @@ -94,8 +57,6 @@ B2007E057701C93D2F6474DC /* Build KMP Framework */, 42DDFD780701DBC1BD02AB98 /* Sources */, 5DA19835EA0C3024B2D5A4B9 /* Resources */, - E1F31206D4AE717D1E2DE8D8 /* Frameworks */, - C396E052E1BD6239F169D5D4 /* Embed Frameworks */, ); buildRules = ( ); @@ -176,7 +137,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd \"$SRCROOT/..\"\n./gradlew :farebot-app:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "cd \"$SRCROOT/../..\"\n./gradlew :app:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -324,11 +285,10 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ZJ9GEQ36AH; FRAMEWORK_SEARCH_PATHS = ( - "$(SRCROOT)/../farebot-app/build/XCFrameworks/release", - "$(SRCROOT)/../farebot-app/build/bin/iosSimulatorArm64/debugFramework", - "$(SRCROOT)/../farebot-app/build/bin/iosArm64/debugFramework", - "$(SRCROOT)/../farebot-app/build/bin/iosX64/debugFramework", - "\"../farebot-app/build/bin/iosSimulatorArm64/debugFramework\"", + "$(SRCROOT)/../../app/build/XCFrameworks/release", + "$(SRCROOT)/../../app/build/bin/iosSimulatorArm64/debugFramework", + "$(SRCROOT)/../../app/build/bin/iosArm64/debugFramework", + "$(SRCROOT)/../../app/build/bin/iosX64/debugFramework", ); INFOPLIST_FILE = FareBot/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -357,11 +317,10 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ZJ9GEQ36AH; FRAMEWORK_SEARCH_PATHS = ( - "$(SRCROOT)/../farebot-app/build/XCFrameworks/release", - "$(SRCROOT)/../farebot-app/build/bin/iosSimulatorArm64/debugFramework", - "$(SRCROOT)/../farebot-app/build/bin/iosArm64/debugFramework", - "$(SRCROOT)/../farebot-app/build/bin/iosX64/debugFramework", - "\"../farebot-app/build/bin/iosSimulatorArm64/debugFramework\"", + "$(SRCROOT)/../../app/build/XCFrameworks/release", + "$(SRCROOT)/../../app/build/bin/iosSimulatorArm64/debugFramework", + "$(SRCROOT)/../../app/build/bin/iosArm64/debugFramework", + "$(SRCROOT)/../../app/build/bin/iosX64/debugFramework", ); INFOPLIST_FILE = FareBot/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/app/ios/project.yml b/app/ios/project.yml index a55dc0d18..a67bcb4b1 100644 --- a/app/ios/project.yml +++ b/app/ios/project.yml @@ -38,9 +38,6 @@ targets: SystemCapabilities: com.apple.NearFieldCommunicationTagReading: enabled: 1 - dependencies: - - framework: "../../app/build/bin/iosSimulatorArm64/debugFramework/FareBotKit.framework" - embed: true preBuildScripts: - name: "Build KMP Framework" script: | diff --git a/app/src/iosMain/kotlin/com/codebutler/farebot/shared/nfc/IosNfcScanner.kt b/app/src/iosMain/kotlin/com/codebutler/farebot/shared/nfc/IosNfcScanner.kt index 97b46328d..e11c808ab 100644 --- a/app/src/iosMain/kotlin/com/codebutler/farebot/shared/nfc/IosNfcScanner.kt +++ b/app/src/iosMain/kotlin/com/codebutler/farebot/shared/nfc/IosNfcScanner.kt @@ -24,6 +24,7 @@ package com.codebutler.farebot.shared.nfc import com.codebutler.farebot.card.RawCard import com.codebutler.farebot.card.cepas.CEPASCardReader +import com.codebutler.farebot.card.desfire.DesfireCardReader import com.codebutler.farebot.card.felica.FeliCaReader import com.codebutler.farebot.card.felica.IosFeliCaTagAdapter import com.codebutler.farebot.card.nfc.IosCardTransceiver @@ -33,19 +34,23 @@ import com.codebutler.farebot.card.nfc.toByteArray import com.codebutler.farebot.card.ultralight.UltralightCardReader import com.codebutler.farebot.card.vicinity.VicinityCardReader import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import platform.CoreNFC.NFCFeliCaTagProtocol import platform.CoreNFC.NFCISO15693TagProtocol import platform.CoreNFC.NFCMiFareDESFire import platform.CoreNFC.NFCMiFareTagProtocol import platform.CoreNFC.NFCMiFareUltralight import platform.CoreNFC.NFCPollingISO14443 +import platform.CoreNFC.NFCPollingISO15693 import platform.CoreNFC.NFCPollingISO18092 import platform.CoreNFC.NFCTagReaderSession import platform.CoreNFC.NFCTagReaderSessionDelegateProtocol @@ -116,7 +121,7 @@ class IosNfcScanner : CardScanner { dispatch_async(dispatch_get_main_queue()) { val newSession = NFCTagReaderSession( - pollingOption = NFCPollingISO14443 or NFCPollingISO18092, + pollingOption = NFCPollingISO14443 or NFCPollingISO15693 or NFCPollingISO18092, delegate = scanDelegate, queue = nfcQueue, ) @@ -170,14 +175,40 @@ class IosNfcScanner : CardScanner { } session.alertMessage = "Reading card… Keep holding." - try { - val rawCard = readTag(tag) - session.alertMessage = "Done!" - session.invalidateSession() - onCardScanned(rawCard) - } catch (e: Exception) { + + // Bridge suspend card readers using coroutine + GCD semaphore. + // We use CoroutineScope(Dispatchers.IO) instead of runBlocking to avoid + // interfering with GCD's management of the workerQueue thread. + val readSemaphore = dispatch_semaphore_create(0) + var rawCard: RawCard<*>? = null + var readException: Exception? = null + + CoroutineScope(Dispatchers.IO).launch { + try { + rawCard = readTag(tag) + } catch (e: Exception) { + readException = e + } finally { + dispatch_semaphore_signal(readSemaphore) + } + } + + dispatch_semaphore_wait(readSemaphore, DISPATCH_TIME_FOREVER) + + readException?.let { e -> session.invalidateSessionWithErrorMessage("Read failed: ${e.message}") onError("Read failed: ${e.message ?: "Unknown error"}") + return@dispatch_async + } + + val card = rawCard + if (card != null) { + session.alertMessage = "Done!" + session.invalidateSession() + onCardScanned(card) + } else { + session.invalidateSessionWithErrorMessage("Read failed: no card data") + onError("Read failed: no card data") } } } @@ -197,14 +228,12 @@ class IosNfcScanner : CardScanner { override fun tagReaderSessionDidBecomeActive(session: NFCTagReaderSession) { } - private fun readTag(tag: Any): RawCard<*> = - runBlocking { - when (tag) { - is NFCFeliCaTagProtocol -> readFelicaTag(tag) - is NFCMiFareTagProtocol -> readMiFareTag(tag) - is NFCISO15693TagProtocol -> readVicinityTag(tag) - else -> throw Exception("Unsupported NFC tag type") - } + private suspend fun readTag(tag: Any): RawCard<*> = + when (tag) { + is NFCFeliCaTagProtocol -> readFelicaTag(tag) + is NFCMiFareTagProtocol -> readMiFareTag(tag) + is NFCISO15693TagProtocol -> readVicinityTag(tag) + else -> throw Exception("Unsupported NFC tag type") } private suspend fun readFelicaTag(tag: NFCFeliCaTagProtocol): RawCard<*> { @@ -228,10 +257,14 @@ class IosNfcScanner : CardScanner { val tagId = tag.identifier.toByteArray() return when (tag.mifareFamily) { NFCMiFareDESFire -> { + // Use DESFire native protocol directly. iOS requires AIDs to be + // registered in Info.plist for ISO 7816 SELECT commands — an + // unregistered AID causes Core NFC to kill the entire session. + // DESFire native protocol avoids this by not sending SELECT commands. val transceiver = IosCardTransceiver(tag) transceiver.connect() try { - ISO7816Dispatcher.readCard(tagId, transceiver) + DesfireCardReader.readCard(tagId, transceiver) } finally { if (transceiver.isConnected) { try { diff --git a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt index b4f213586..986de24d6 100644 --- a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt +++ b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt @@ -118,7 +118,11 @@ class WebCardScanner : CardScanner { val fw = pn533.getFirmwareVersion() println("[WebUSB] PN53x firmware: $fw") pn533.samConfiguration() - pn533.setMaxRetries(passiveActivation = 0x02) + // Use finite ATR retries on WebUSB. WebUSB's transferIn cannot be + // cancelled, so InListPassiveTarget must self-resolve within its own + // timeout rather than relying on client-side abort. With atrRetries=2, + // the PN533 polls ~2 times (~300ms) then returns NbTg=0. + pn533.setMaxRetries(atrRetries = 0x02, passiveActivation = 0x02) while (true) { // Try ISO 14443-A (covers Classic, Ultralight, DESFire) diff --git a/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireProtocol.kt b/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireProtocol.kt index bd755a027..53a142952 100644 --- a/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireProtocol.kt +++ b/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireProtocol.kt @@ -208,6 +208,7 @@ internal class DesfireProtocol( } PERMISSION_DENIED -> throw DesfireAccessControlException("Permission denied") AUTHENTICATION_ERROR -> throw DesfireAccessControlException("Authentication error") + COMMAND_ABORTED -> throw DesfireAccessControlException("Command aborted") AID_NOT_FOUND -> throw DesfireNotFoundException("AID not found") FILE_NOT_FOUND -> throw DesfireNotFoundException("File not found") else -> throw Exception("Unknown status code: " + (status.toInt() and 0xFF).toString(16)) @@ -259,6 +260,7 @@ internal class DesfireProtocol( private val AID_NOT_FOUND: Byte = 0xA0.toByte() private val AUTHENTICATION_ERROR: Byte = 0xAE.toByte() private val ADDITIONAL_FRAME: Byte = 0xAF.toByte() + private val COMMAND_ABORTED: Byte = 0xCA.toByte() private val FILE_NOT_FOUND: Byte = 0xF0.toByte() } } diff --git a/card/felica/src/iosMain/kotlin/com/codebutler/farebot/card/felica/IosFeliCaTagAdapter.kt b/card/felica/src/iosMain/kotlin/com/codebutler/farebot/card/felica/IosFeliCaTagAdapter.kt index 2bdc92404..f704fd819 100644 --- a/card/felica/src/iosMain/kotlin/com/codebutler/farebot/card/felica/IosFeliCaTagAdapter.kt +++ b/card/felica/src/iosMain/kotlin/com/codebutler/farebot/card/felica/IosFeliCaTagAdapter.kt @@ -25,20 +25,19 @@ package com.codebutler.farebot.card.felica import com.codebutler.farebot.card.nfc.toByteArray import com.codebutler.farebot.card.nfc.toNSData import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.suspendCancellableCoroutine import platform.CoreNFC.NFCFeliCaPollingRequestCodeNoRequest import platform.CoreNFC.NFCFeliCaPollingTimeSlotMax1 import platform.CoreNFC.NFCFeliCaTagProtocol import platform.Foundation.NSData import platform.Foundation.NSError -import platform.darwin.DISPATCH_TIME_FOREVER -import platform.darwin.dispatch_semaphore_create -import platform.darwin.dispatch_semaphore_signal -import platform.darwin.dispatch_semaphore_wait +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * iOS implementation of [FeliCaTagAdapter] using Core NFC's [NFCFeliCaTagProtocol]. * - * Uses semaphore-based bridging for the async Core NFC API. + * Uses [suspendCancellableCoroutine] to bridge the async Core NFC API. */ @OptIn(ExperimentalForeignApi::class) class IosFeliCaTagAdapter( @@ -47,19 +46,22 @@ class IosFeliCaTagAdapter( override fun getIDm(): ByteArray = tag.currentIDm.toByteArray() override suspend fun getSystemCodes(): List { - val semaphore = dispatch_semaphore_create(0) - var codes: List<*>? = null - var nfcError: NSError? = null - - tag.requestSystemCodeWithCompletionHandler { systemCodes: List<*>?, error: NSError? -> - codes = systemCodes - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - if (nfcError != null) return emptyList() + val codes = + try { + suspendCancellableCoroutine?> { cont -> + tag.requestSystemCodeWithCompletionHandler { systemCodes: List<*>?, error: NSError? -> + if (error != null) { + cont.resumeWithException( + Exception("requestSystemCode failed: ${error.localizedDescription}"), + ) + } else { + cont.resume(systemCodes) + } + } + } + } catch (_: Exception) { + return emptyList() + } return codes?.mapNotNull { item -> val data = item as? NSData ?: return@mapNotNull null @@ -73,30 +75,29 @@ class IosFeliCaTagAdapter( } override suspend fun selectSystem(systemCode: Int): ByteArray? { - val semaphore = dispatch_semaphore_create(0) - var pmmData: NSData? = null - var nfcError: NSError? = null - val systemCodeBytes = byteArrayOf( (systemCode shr 8).toByte(), (systemCode and 0xff).toByte(), ) - tag.pollingWithSystemCode( - systemCodeBytes.toNSData(), - requestCode = NFCFeliCaPollingRequestCodeNoRequest, - timeSlot = NFCFeliCaPollingTimeSlotMax1, - ) { pmm: NSData?, _: NSData?, error: NSError? -> - pmmData = pmm - nfcError = error - dispatch_semaphore_signal(semaphore) + return try { + suspendCancellableCoroutine { cont -> + tag.pollingWithSystemCode( + systemCodeBytes.toNSData(), + requestCode = NFCFeliCaPollingRequestCodeNoRequest, + timeSlot = NFCFeliCaPollingTimeSlotMax1, + ) { pmm: NSData?, _: NSData?, error: NSError? -> + if (error != null) { + cont.resume(null) + } else { + cont.resume(pmm?.toByteArray()) + } + } + } + } catch (_: Exception) { + null } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - if (nfcError != null) return null - return pmmData?.toByteArray() } override suspend fun getServiceCodes(): List { @@ -126,10 +127,6 @@ class IosFeliCaTagAdapter( serviceCode: Int, blockAddr: Byte, ): ByteArray? { - val semaphore = dispatch_semaphore_create(0) - var blockDataList: List<*>? = null - var nfcError: NSError? = null - // Service code list: 2 bytes, little-endian val serviceCodeData = byteArrayOf( @@ -140,29 +137,27 @@ class IosFeliCaTagAdapter( // Block list element: 2-byte format (0x80 | service_list_order, block_number) val blockListData = byteArrayOf(0x80.toByte(), blockAddr).toNSData() - tag.readWithoutEncryptionWithServiceCodeList( - listOf(serviceCodeData), - blockList = listOf(blockListData), - ) { _: Long, _: Long, dataList: List<*>?, error: NSError? -> - blockDataList = dataList - nfcError = error - dispatch_semaphore_signal(semaphore) + return try { + suspendCancellableCoroutine { cont -> + tag.readWithoutEncryptionWithServiceCodeList( + listOf(serviceCodeData), + blockList = listOf(blockListData), + ) { _: Long, _: Long, dataList: List<*>?, error: NSError? -> + if (error != null) { + cont.resume(null) + } else { + val data = dataList?.firstOrNull() as? NSData + val bytes = data?.toByteArray() + cont.resume(if (bytes != null && bytes.isNotEmpty()) bytes else null) + } + } + } + } catch (_: Exception) { + null } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - if (nfcError != null) return null - - val data = blockDataList?.firstOrNull() as? NSData ?: return null - val bytes = data.toByteArray() - return if (bytes.isNotEmpty()) bytes else null } - private fun requestServiceVersions(serviceCodes: List): List? { - val semaphore = dispatch_semaphore_create(0) - var versionList: List<*>? = null - var nfcError: NSError? = null - + private suspend fun requestServiceVersions(serviceCodes: List): List? { val nodeCodeList = serviceCodes.map { code -> byteArrayOf( @@ -171,24 +166,28 @@ class IosFeliCaTagAdapter( ).toNSData() } - tag.requestServiceWithNodeCodeList(nodeCodeList) { versions: List<*>?, error: NSError? -> - versionList = versions - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - if (nfcError != null) return null - - return versionList?.map { item -> - val data = item as? NSData ?: return@map 0xFFFF - val bytes = data.toByteArray() - if (bytes.size >= 2) { - (bytes[0].toInt() and 0xff) or ((bytes[1].toInt() and 0xff) shl 8) - } else { - 0xFFFF + return try { + suspendCancellableCoroutine?> { cont -> + tag.requestServiceWithNodeCodeList(nodeCodeList) { versions: List<*>?, error: NSError? -> + if (error != null) { + cont.resume(null) + } else { + cont.resume( + versions?.map { item -> + val data = item as? NSData ?: return@map 0xFFFF + val bytes = data.toByteArray() + if (bytes.size >= 2) { + (bytes[0].toInt() and 0xff) or ((bytes[1].toInt() and 0xff) shl 8) + } else { + 0xFFFF + } + }, + ) + } + } } + } catch (_: Exception) { + null } } diff --git a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosCardTransceiver.kt b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosCardTransceiver.kt index 90c6a5e75..1e73199e2 100644 --- a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosCardTransceiver.kt +++ b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosCardTransceiver.kt @@ -23,13 +23,12 @@ package com.codebutler.farebot.card.nfc import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.suspendCancellableCoroutine import platform.CoreNFC.NFCMiFareTagProtocol import platform.Foundation.NSData import platform.Foundation.NSError -import platform.darwin.DISPATCH_TIME_FOREVER -import platform.darwin.dispatch_semaphore_create -import platform.darwin.dispatch_semaphore_signal -import platform.darwin.dispatch_semaphore_wait +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * iOS implementation of [CardTransceiver] wrapping Core NFC's [NFCMiFareTag]. @@ -39,8 +38,7 @@ import platform.darwin.dispatch_semaphore_wait * [DesfireProtocol] and [CEPASProtocol] use through [transceive]. * * Core NFC APIs are asynchronous (completion handler based). This wrapper bridges - * them to the synchronous [CardTransceiver] interface using dispatch semaphores, - * which is safe because tag reading runs on a background thread. + * them to the suspend [CardTransceiver] interface using [suspendCancellableCoroutine]. */ @OptIn(ExperimentalForeignApi::class) class IosCardTransceiver( @@ -61,27 +59,19 @@ class IosCardTransceiver( override val isConnected: Boolean get() = _isConnected - override suspend fun transceive(data: ByteArray): ByteArray { - val semaphore = dispatch_semaphore_create(0) - var result: NSData? = null - var nfcError: NSError? = null - - tag.sendMiFareCommand(data.toNSData()) { response: NSData?, error: NSError? -> - result = response - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - nfcError?.let { - throw Exception("NFC transceive failed: ${it.localizedDescription}") + override suspend fun transceive(data: ByteArray): ByteArray = + suspendCancellableCoroutine { cont -> + tag.sendMiFareCommand(data.toNSData()) { response: NSData?, error: NSError? -> + if (error != null) { + cont.resumeWithException(Exception("NFC transceive failed: ${error.localizedDescription}")) + } else if (response != null) { + cont.resume(response.toByteArray()) + } else { + cont.resumeWithException(Exception("NFC transceive returned null response")) + } + } } - return result?.toByteArray() - ?: throw Exception("NFC transceive returned null response") - } - override val maxTransceiveLength: Int get() = 253 // ISO 7816 APDU maximum command length } diff --git a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosUltralightTechnology.kt b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosUltralightTechnology.kt index f1a9def06..496b721e0 100644 --- a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosUltralightTechnology.kt +++ b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosUltralightTechnology.kt @@ -23,14 +23,13 @@ package com.codebutler.farebot.card.nfc import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.suspendCancellableCoroutine import platform.CoreNFC.NFCMiFareTagProtocol import platform.CoreNFC.NFCMiFareUltralight import platform.Foundation.NSData import platform.Foundation.NSError -import platform.darwin.DISPATCH_TIME_FOREVER -import platform.darwin.dispatch_semaphore_create -import platform.darwin.dispatch_semaphore_signal -import platform.darwin.dispatch_semaphore_wait +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * iOS implementation of [UltralightTechnology] wrapping Core NFC's [NFCMiFareTag]. @@ -68,44 +67,33 @@ class IosUltralightTechnology( // Returns 16 bytes (4 consecutive pages of 4 bytes each). val readCommand = byteArrayOf(0x30, pageOffset.toByte()) - val semaphore = dispatch_semaphore_create(0) - var result: NSData? = null - var nfcError: NSError? = null - - tag.sendMiFareCommand(readCommand.toNSData()) { response: NSData?, error: NSError? -> - result = response - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - nfcError?.let { - throw Exception("Ultralight read failed at page $pageOffset: ${it.localizedDescription}") + return suspendCancellableCoroutine { cont -> + tag.sendMiFareCommand(readCommand.toNSData()) { response: NSData?, error: NSError? -> + if (error != null) { + cont.resumeWithException( + Exception("Ultralight read failed at page $pageOffset: ${error.localizedDescription}"), + ) + } else if (response != null) { + cont.resume(response.toByteArray()) + } else { + cont.resumeWithException( + Exception("Ultralight read returned null at page $pageOffset"), + ) + } + } } - - return result?.toByteArray() - ?: throw Exception("Ultralight read returned null at page $pageOffset") } - override suspend fun transceive(data: ByteArray): ByteArray { - val semaphore = dispatch_semaphore_create(0) - var result: NSData? = null - var nfcError: NSError? = null - - tag.sendMiFareCommand(data.toNSData()) { response: NSData?, error: NSError? -> - result = response - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - nfcError?.let { - throw Exception("Ultralight transceive failed: ${it.localizedDescription}") + override suspend fun transceive(data: ByteArray): ByteArray = + suspendCancellableCoroutine { cont -> + tag.sendMiFareCommand(data.toNSData()) { response: NSData?, error: NSError? -> + if (error != null) { + cont.resumeWithException(Exception("Ultralight transceive failed: ${error.localizedDescription}")) + } else if (response != null) { + cont.resume(response.toByteArray()) + } else { + cont.resumeWithException(Exception("Ultralight transceive returned null")) + } + } } - - return result?.toByteArray() - ?: throw Exception("Ultralight transceive returned null") - } } diff --git a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosVicinityTechnology.kt b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosVicinityTechnology.kt index 4910edc02..c9b331e3b 100644 --- a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosVicinityTechnology.kt +++ b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosVicinityTechnology.kt @@ -23,18 +23,17 @@ package com.codebutler.farebot.card.nfc import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.suspendCancellableCoroutine import platform.CoreNFC.NFCISO15693TagProtocol import platform.Foundation.NSData import platform.Foundation.NSError -import platform.darwin.DISPATCH_TIME_FOREVER -import platform.darwin.dispatch_semaphore_create -import platform.darwin.dispatch_semaphore_signal -import platform.darwin.dispatch_semaphore_wait +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * iOS implementation of [VicinityTechnology] using Core NFC's [NFCISO15693TagProtocol]. * - * Uses semaphore-based bridging for the async Core NFC API. + * Uses [suspendCancellableCoroutine] to bridge the async Core NFC API. */ @OptIn(ExperimentalForeignApi::class) class IosVicinityTechnology( @@ -76,30 +75,29 @@ class IosVicinityTechnology( val blockNumber = data[10].toUByte() - val semaphore = dispatch_semaphore_create(0) - var blockData: NSData? = null - var nfcError: NSError? = null - - tag.readSingleBlockWithRequestFlags( - 0x22u, - blockNumber = blockNumber, - ) { data: NSData?, error: NSError? -> - blockData = data - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - nfcError?.let { error -> - when (error.code) { - 102L -> throw EndOfMemoryException() - 100L -> throw TagLostException() - else -> throw Exception("NFC-V read error: ${error.localizedDescription}") + val bytes = + suspendCancellableCoroutine { cont -> + tag.readSingleBlockWithRequestFlags( + 0x22u, + blockNumber = blockNumber, + ) { blockData: NSData?, error: NSError? -> + if (error != null) { + when (error.code) { + 102L -> cont.resumeWithException(EndOfMemoryException()) + 100L -> cont.resumeWithException(TagLostException()) + else -> + cont.resumeWithException( + Exception("NFC-V read error: ${error.localizedDescription}"), + ) + } + } else if (blockData != null) { + cont.resume(blockData.toByteArray()) + } else { + cont.resumeWithException(Exception("No data returned")) + } + } } - } - val bytes = blockData?.toByteArray() ?: throw Exception("No data returned") // Prepend success status byte (0x00) to match Android NfcV.transceive behavior return byteArrayOf(0x00) + bytes } diff --git a/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Device.kt b/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Device.kt index 83e7516df..2fed1c8aa 100644 --- a/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Device.kt +++ b/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Device.kt @@ -43,19 +43,22 @@ object PN533Device { private var context: Context? = null + private fun ensureContext(): Context? { + context?.let { return it } + val ctx = Context() + if (LibUsb.init(ctx) != LibUsb.SUCCESS) return null + context = ctx + return ctx + } + fun open(): Usb4JavaPN533Transport? = openAll().firstOrNull() fun openAll(): List { - val ctx = Context() - val result = LibUsb.init(ctx) - if (result != LibUsb.SUCCESS) { - return emptyList() - } + val ctx = ensureContext() ?: return emptyList() val deviceList = DeviceList() val count = LibUsb.getDeviceList(ctx, deviceList) if (count < 0) { - LibUsb.exit(ctx) return emptyList() } @@ -90,11 +93,6 @@ object PN533Device { LibUsb.freeDeviceList(deviceList, true) } - if (transports.isEmpty()) { - LibUsb.exit(ctx) - } else { - context = ctx - } return transports } diff --git a/card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt b/card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt index 5ab4a0b1b..141fd106e 100644 --- a/card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt +++ b/card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt @@ -80,11 +80,10 @@ class WebUsbPN533Transport : PN533Transport { return false } deviceOpened = true - // Drain stale data - repeat(MAX_FLUSH_READS) { - val drained = bulkRead(FLUSH_TIMEOUT_MS) - drained ?: return@repeat - } + // No flush here — WebUSB transferIn cannot be cancelled, so rapid + // reads with short timeouts leave dangling promises that consume + // subsequent device responses. The poll loop sends an ACK first + // to clear any stale PN533 command state. return true } @@ -153,8 +152,6 @@ class WebUsbPN533Transport : PN533Transport { companion object { const val TIMEOUT_MS = 5000 - const val FLUSH_TIMEOUT_MS = 100 - const val MAX_FLUSH_READS = 10 const val POLL_INTERVAL_MS = 5L const val TFI_HOST_TO_PN533: Byte = 0xD4.toByte() @@ -315,7 +312,7 @@ private fun jsWebUsbStartTransferIn(timeoutMs: Int) { var timer = setTimeout(function() { if (!window._fbUsbIn.ready) window._fbUsbIn.ready = true; }, timeoutMs); - window._fbUsb.device.transferIn(4, 64).then(function(result) { + window._fbUsb.device.transferIn(4, 265).then(function(result) { clearTimeout(timer); if (result.data && result.data.byteLength > 0) { var arr = new Uint8Array(result.data.buffer);