Skip to content

Commit 037f2fe

Browse files
codebutlerClaude
andauthored
fix: replace blocking NFC calls with proper coroutine/suspend APIs across all platforms (#238)
iOS: - Replace dispatch_semaphore bridging with suspendCancellableCoroutine in IosCardTransceiver, IosUltralightTechnology, IosVicinityTechnology, and IosFeliCaTagAdapter - Replace runBlocking in IosNfcScanner with CoroutineScope(Dispatchers.IO) + GCD semaphore to avoid blocking GCD's worker queue thread - Use DESFire native protocol directly instead of ISO 7816 SELECT, which requires AIDs registered in Info.plist (unregistered AIDs kill the session) - Add NFCPollingISO15693 to NFC session polling options for NFC-V support - Fix Xcode project paths from stale farebot-app/ to app/ Desktop: - Make NfcReaderBackend.scanLoop() a suspend function, removing runBlocking from PN53xReaderBackend and PcscReaderBackend - Wrap scan coroutine in try/finally to ensure _isScanning resets on cancel - Share a single libusb context in PN533Device instead of per-call init/exit WebUSB: - Remove flush-on-open which left dangling transferIn promises that consumed subsequent device responses - Increase transferIn buffer from 64 to 265 bytes for full PN533 frames - Pass atrRetries to setMaxRetries so InListPassiveTarget self-resolves instead of relying on client-side abort (which WebUSB can't do) DESFire: - Handle COMMAND_ABORTED (0xCA) status code as access control exception Co-authored-by: Claude <claude@codebutler.com>
1 parent a5b8e57 commit 037f2fe

16 files changed

Lines changed: 261 additions & 299 deletions

File tree

.gitmodules

Whitespace-only changes.

app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopCardScanner.kt

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -71,49 +71,50 @@ class DesktopCardScanner : CardScanner {
7171

7272
scanJob =
7373
scope.launch {
74-
val backends = discoverBackends()
75-
val backendJobs =
76-
backends.map { backend ->
77-
launch {
78-
println("[DesktopCardScanner] Starting ${backend.name} backend")
79-
try {
80-
backend.scanLoop(
81-
onCardDetected = { tag ->
82-
_scannedTags.tryEmit(tag)
83-
},
84-
onCardRead = { rawCard ->
85-
_scannedCards.tryEmit(rawCard)
86-
},
87-
onError = { error ->
88-
_scanErrors.tryEmit(error)
89-
},
90-
)
91-
} catch (e: Exception) {
92-
if (isActive) {
93-
println("[DesktopCardScanner] ${backend.name} backend failed: ${e.message}")
74+
try {
75+
val backends = discoverBackends()
76+
val backendJobs =
77+
backends.map { backend ->
78+
launch {
79+
println("[DesktopCardScanner] Starting ${backend.name} backend")
80+
try {
81+
backend.scanLoop(
82+
onCardDetected = { tag ->
83+
_scannedTags.tryEmit(tag)
84+
},
85+
onCardRead = { rawCard ->
86+
_scannedCards.tryEmit(rawCard)
87+
},
88+
onError = { error ->
89+
_scanErrors.tryEmit(error)
90+
},
91+
)
92+
} catch (e: Exception) {
93+
if (isActive) {
94+
println("[DesktopCardScanner] ${backend.name} backend failed: ${e.message}")
95+
}
96+
} catch (e: Error) {
97+
// Catch LinkageError / UnsatisfiedLinkError from native libs
98+
println("[DesktopCardScanner] ${backend.name} backend unavailable: ${e.message}")
9499
}
95-
} catch (e: Error) {
96-
// Catch LinkageError / UnsatisfiedLinkError from native libs
97-
println("[DesktopCardScanner] ${backend.name} backend unavailable: ${e.message}")
98100
}
99101
}
100-
}
101102

102-
backendJobs.forEach { it.join() }
103+
backendJobs.forEach { it.join() }
103104

104-
// All backends exited — emit error only if none ran successfully
105-
if (isActive) {
106-
_scanErrors.tryEmit(Exception("All NFC reader backends failed. Is a USB NFC reader connected?"))
105+
// All backends exited — emit error only if none ran successfully
106+
if (isActive) {
107+
_scanErrors.tryEmit(Exception("All NFC reader backends failed. Is a USB NFC reader connected?"))
108+
}
109+
} finally {
110+
_isScanning.value = false
107111
}
108-
_isScanning.value = false
109112
}
110113
}
111114

112115
override fun stopActiveScan() {
113116
scanJob?.cancel()
114117
scanJob = null
115-
_isScanning.value = false
116-
PN533Device.shutdown()
117118
}
118119

119120
private suspend fun discoverBackends(): List<NfcReaderBackend> {

app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/NfcReaderBackend.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import com.codebutler.farebot.shared.nfc.ScannedTag
3636
interface NfcReaderBackend {
3737
val name: String
3838

39-
fun scanLoop(
39+
suspend fun scanLoop(
4040
onCardDetected: (ScannedTag) -> Unit,
4141
onCardRead: (RawCard<*>) -> Unit,
4242
onError: (Throwable) -> Unit,

app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ import com.codebutler.farebot.card.ultralight.UltralightCardReader
4141
import com.codebutler.farebot.shared.nfc.ISO7816Dispatcher
4242
import com.codebutler.farebot.shared.nfc.ScannedTag
4343
import kotlinx.coroutines.delay
44-
import kotlinx.coroutines.runBlocking
4544

4645
/**
4746
* Abstract base for PN53x-family USB reader backends.
@@ -59,7 +58,7 @@ abstract class PN53xReaderBackend(
5958
tg: Int,
6059
): CardTransceiver = PN533CardTransceiver(pn533, tg)
6160

62-
override fun scanLoop(
61+
override suspend fun scanLoop(
6362
onCardDetected: (ScannedTag) -> Unit,
6463
onCardRead: (RawCard<*>) -> Unit,
6564
onError: (Throwable) -> Unit,
@@ -72,10 +71,8 @@ abstract class PN53xReaderBackend(
7271
transport.flush()
7372
val pn533 = PN533(transport)
7473
try {
75-
runBlocking {
76-
initDevice(pn533)
77-
pollLoop(pn533, onCardDetected, onCardRead, onError)
78-
}
74+
initDevice(pn533)
75+
pollLoop(pn533, onCardDetected, onCardRead, onError)
7976
} finally {
8077
pn533.close()
8178
}

app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ import com.codebutler.farebot.card.ultralight.UltralightCardReader
3737
import com.codebutler.farebot.card.vicinity.VicinityCardReader
3838
import com.codebutler.farebot.shared.nfc.ISO7816Dispatcher
3939
import com.codebutler.farebot.shared.nfc.ScannedTag
40-
import kotlinx.coroutines.runBlocking
4140
import javax.smartcardio.CardException
4241
import javax.smartcardio.CommandAPDU
4342
import javax.smartcardio.TerminalFactory
@@ -51,7 +50,7 @@ import javax.smartcardio.TerminalFactory
5150
class PcscReaderBackend : NfcReaderBackend {
5251
override val name: String = "PC/SC"
5352

54-
override fun scanLoop(
53+
override suspend fun scanLoop(
5554
onCardDetected: (ScannedTag) -> Unit,
5655
onCardRead: (RawCard<*>) -> Unit,
5756
onError: (Throwable) -> Unit,
@@ -96,7 +95,7 @@ class PcscReaderBackend : NfcReaderBackend {
9695
println("[PC/SC] Tag ID: ${tagId.hex()}")
9796

9897
onCardDetected(ScannedTag(id = tagId, techList = listOf(info.cardType.name)))
99-
val rawCard = runBlocking { readCard(info, channel, tagId) }
98+
val rawCard = readCard(info, channel, tagId)
10099
onCardRead(rawCard)
101100
println("[PC/SC] Card read successfully")
102101
} finally {

app/ios/FareBot.xcodeproj/project.pbxproj

Lines changed: 9 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,46 +7,18 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10-
7BFF0BD60CC51FB78D8A764D /* FareBotKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E296318A4ABC8EE549B0C47E /* FareBotKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
1110
8E11E423477F24B274729679 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 534508E7AAA01FF336ECDC0C /* iOSApp.swift */; };
1211
D52C887B87D2D7CD2DF7A030 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 445C357A8AB1DD9317170556 /* Assets.xcassets */; };
13-
EA3AC0F2B800448FB22567C4 /* FareBotKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296318A4ABC8EE549B0C47E /* FareBotKit.framework */; };
1412
/* End PBXBuildFile section */
1513

16-
/* Begin PBXCopyFilesBuildPhase section */
17-
C396E052E1BD6239F169D5D4 /* Embed Frameworks */ = {
18-
isa = PBXCopyFilesBuildPhase;
19-
buildActionMask = 2147483647;
20-
dstPath = "";
21-
dstSubfolderSpec = 10;
22-
files = (
23-
7BFF0BD60CC51FB78D8A764D /* FareBotKit.framework in Embed Frameworks */,
24-
);
25-
name = "Embed Frameworks";
26-
runOnlyForDeploymentPostprocessing = 0;
27-
};
28-
/* End PBXCopyFilesBuildPhase section */
29-
3014
/* Begin PBXFileReference section */
3115
154ABFFD520502DDADF58B61 /* FareBot.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FareBot.app; sourceTree = BUILT_PRODUCTS_DIR; };
3216
445C357A8AB1DD9317170556 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
3317
534508E7AAA01FF336ECDC0C /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
3418
A893E13DD60D0B10ECB49A59 /* FareBot.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FareBot.entitlements; sourceTree = "<group>"; };
35-
E296318A4ABC8EE549B0C47E /* FareBotKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FareBotKit.framework; path = "../farebot-app/build/bin/iosSimulatorArm64/debugFramework/FareBotKit.framework"; sourceTree = "<group>"; };
3619
E65B641D90F72BA2E1DEAFF7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
3720
/* End PBXFileReference section */
3821

39-
/* Begin PBXFrameworksBuildPhase section */
40-
E1F31206D4AE717D1E2DE8D8 /* Frameworks */ = {
41-
isa = PBXFrameworksBuildPhase;
42-
buildActionMask = 2147483647;
43-
files = (
44-
EA3AC0F2B800448FB22567C4 /* FareBotKit.framework in Frameworks */,
45-
);
46-
runOnlyForDeploymentPostprocessing = 0;
47-
};
48-
/* End PBXFrameworksBuildPhase section */
49-
5022
/* Begin PBXGroup section */
5123
2098F79B3F3B6A526575D03F /* Products */ = {
5224
isa = PBXGroup;
@@ -67,19 +39,10 @@
6739
path = FareBot;
6840
sourceTree = "<group>";
6941
};
70-
9B0F3B4C26A1726B3C4A2BE9 /* Frameworks */ = {
71-
isa = PBXGroup;
72-
children = (
73-
E296318A4ABC8EE549B0C47E /* FareBotKit.framework */,
74-
);
75-
name = Frameworks;
76-
sourceTree = "<group>";
77-
};
7842
E8645766090C58DFD719F43E = {
7943
isa = PBXGroup;
8044
children = (
8145
35C5B4B3C4B8B2643DF5E68A /* FareBot */,
82-
9B0F3B4C26A1726B3C4A2BE9 /* Frameworks */,
8346
2098F79B3F3B6A526575D03F /* Products */,
8447
);
8548
sourceTree = "<group>";
@@ -94,8 +57,6 @@
9457
B2007E057701C93D2F6474DC /* Build KMP Framework */,
9558
42DDFD780701DBC1BD02AB98 /* Sources */,
9659
5DA19835EA0C3024B2D5A4B9 /* Resources */,
97-
E1F31206D4AE717D1E2DE8D8 /* Frameworks */,
98-
C396E052E1BD6239F169D5D4 /* Embed Frameworks */,
9960
);
10061
buildRules = (
10162
);
@@ -176,7 +137,7 @@
176137
);
177138
runOnlyForDeploymentPostprocessing = 0;
178139
shellPath = /bin/sh;
179-
shellScript = "cd \"$SRCROOT/..\"\n./gradlew :farebot-app:embedAndSignAppleFrameworkForXcode\n";
140+
shellScript = "cd \"$SRCROOT/../..\"\n./gradlew :app:embedAndSignAppleFrameworkForXcode\n";
180141
};
181142
/* End PBXShellScriptBuildPhase section */
182143

@@ -324,11 +285,10 @@
324285
CODE_SIGN_STYLE = Automatic;
325286
DEVELOPMENT_TEAM = ZJ9GEQ36AH;
326287
FRAMEWORK_SEARCH_PATHS = (
327-
"$(SRCROOT)/../farebot-app/build/XCFrameworks/release",
328-
"$(SRCROOT)/../farebot-app/build/bin/iosSimulatorArm64/debugFramework",
329-
"$(SRCROOT)/../farebot-app/build/bin/iosArm64/debugFramework",
330-
"$(SRCROOT)/../farebot-app/build/bin/iosX64/debugFramework",
331-
"\"../farebot-app/build/bin/iosSimulatorArm64/debugFramework\"",
288+
"$(SRCROOT)/../../app/build/XCFrameworks/release",
289+
"$(SRCROOT)/../../app/build/bin/iosSimulatorArm64/debugFramework",
290+
"$(SRCROOT)/../../app/build/bin/iosArm64/debugFramework",
291+
"$(SRCROOT)/../../app/build/bin/iosX64/debugFramework",
332292
);
333293
INFOPLIST_FILE = FareBot/Info.plist;
334294
LD_RUNPATH_SEARCH_PATHS = (
@@ -357,11 +317,10 @@
357317
CODE_SIGN_STYLE = Automatic;
358318
DEVELOPMENT_TEAM = ZJ9GEQ36AH;
359319
FRAMEWORK_SEARCH_PATHS = (
360-
"$(SRCROOT)/../farebot-app/build/XCFrameworks/release",
361-
"$(SRCROOT)/../farebot-app/build/bin/iosSimulatorArm64/debugFramework",
362-
"$(SRCROOT)/../farebot-app/build/bin/iosArm64/debugFramework",
363-
"$(SRCROOT)/../farebot-app/build/bin/iosX64/debugFramework",
364-
"\"../farebot-app/build/bin/iosSimulatorArm64/debugFramework\"",
320+
"$(SRCROOT)/../../app/build/XCFrameworks/release",
321+
"$(SRCROOT)/../../app/build/bin/iosSimulatorArm64/debugFramework",
322+
"$(SRCROOT)/../../app/build/bin/iosArm64/debugFramework",
323+
"$(SRCROOT)/../../app/build/bin/iosX64/debugFramework",
365324
);
366325
INFOPLIST_FILE = FareBot/Info.plist;
367326
LD_RUNPATH_SEARCH_PATHS = (

app/ios/project.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,6 @@ targets:
3838
SystemCapabilities:
3939
com.apple.NearFieldCommunicationTagReading:
4040
enabled: 1
41-
dependencies:
42-
- framework: "../../app/build/bin/iosSimulatorArm64/debugFramework/FareBotKit.framework"
43-
embed: true
4441
preBuildScripts:
4542
- name: "Build KMP Framework"
4643
script: |

app/src/iosMain/kotlin/com/codebutler/farebot/shared/nfc/IosNfcScanner.kt

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ package com.codebutler.farebot.shared.nfc
2424

2525
import com.codebutler.farebot.card.RawCard
2626
import com.codebutler.farebot.card.cepas.CEPASCardReader
27+
import com.codebutler.farebot.card.desfire.DesfireCardReader
2728
import com.codebutler.farebot.card.felica.FeliCaReader
2829
import com.codebutler.farebot.card.felica.IosFeliCaTagAdapter
2930
import com.codebutler.farebot.card.nfc.IosCardTransceiver
@@ -33,19 +34,23 @@ import com.codebutler.farebot.card.nfc.toByteArray
3334
import com.codebutler.farebot.card.ultralight.UltralightCardReader
3435
import com.codebutler.farebot.card.vicinity.VicinityCardReader
3536
import kotlinx.cinterop.ExperimentalForeignApi
37+
import kotlinx.coroutines.CoroutineScope
38+
import kotlinx.coroutines.Dispatchers
39+
import kotlinx.coroutines.IO
3640
import kotlinx.coroutines.flow.MutableSharedFlow
3741
import kotlinx.coroutines.flow.MutableStateFlow
3842
import kotlinx.coroutines.flow.SharedFlow
3943
import kotlinx.coroutines.flow.StateFlow
4044
import kotlinx.coroutines.flow.asSharedFlow
4145
import kotlinx.coroutines.flow.asStateFlow
42-
import kotlinx.coroutines.runBlocking
46+
import kotlinx.coroutines.launch
4347
import platform.CoreNFC.NFCFeliCaTagProtocol
4448
import platform.CoreNFC.NFCISO15693TagProtocol
4549
import platform.CoreNFC.NFCMiFareDESFire
4650
import platform.CoreNFC.NFCMiFareTagProtocol
4751
import platform.CoreNFC.NFCMiFareUltralight
4852
import platform.CoreNFC.NFCPollingISO14443
53+
import platform.CoreNFC.NFCPollingISO15693
4954
import platform.CoreNFC.NFCPollingISO18092
5055
import platform.CoreNFC.NFCTagReaderSession
5156
import platform.CoreNFC.NFCTagReaderSessionDelegateProtocol
@@ -116,7 +121,7 @@ class IosNfcScanner : CardScanner {
116121
dispatch_async(dispatch_get_main_queue()) {
117122
val newSession =
118123
NFCTagReaderSession(
119-
pollingOption = NFCPollingISO14443 or NFCPollingISO18092,
124+
pollingOption = NFCPollingISO14443 or NFCPollingISO15693 or NFCPollingISO18092,
120125
delegate = scanDelegate,
121126
queue = nfcQueue,
122127
)
@@ -170,14 +175,40 @@ class IosNfcScanner : CardScanner {
170175
}
171176

172177
session.alertMessage = "Reading card… Keep holding."
173-
try {
174-
val rawCard = readTag(tag)
175-
session.alertMessage = "Done!"
176-
session.invalidateSession()
177-
onCardScanned(rawCard)
178-
} catch (e: Exception) {
178+
179+
// Bridge suspend card readers using coroutine + GCD semaphore.
180+
// We use CoroutineScope(Dispatchers.IO) instead of runBlocking to avoid
181+
// interfering with GCD's management of the workerQueue thread.
182+
val readSemaphore = dispatch_semaphore_create(0)
183+
var rawCard: RawCard<*>? = null
184+
var readException: Exception? = null
185+
186+
CoroutineScope(Dispatchers.IO).launch {
187+
try {
188+
rawCard = readTag(tag)
189+
} catch (e: Exception) {
190+
readException = e
191+
} finally {
192+
dispatch_semaphore_signal(readSemaphore)
193+
}
194+
}
195+
196+
dispatch_semaphore_wait(readSemaphore, DISPATCH_TIME_FOREVER)
197+
198+
readException?.let { e ->
179199
session.invalidateSessionWithErrorMessage("Read failed: ${e.message}")
180200
onError("Read failed: ${e.message ?: "Unknown error"}")
201+
return@dispatch_async
202+
}
203+
204+
val card = rawCard
205+
if (card != null) {
206+
session.alertMessage = "Done!"
207+
session.invalidateSession()
208+
onCardScanned(card)
209+
} else {
210+
session.invalidateSessionWithErrorMessage("Read failed: no card data")
211+
onError("Read failed: no card data")
181212
}
182213
}
183214
}
@@ -197,14 +228,12 @@ class IosNfcScanner : CardScanner {
197228
override fun tagReaderSessionDidBecomeActive(session: NFCTagReaderSession) {
198229
}
199230

200-
private fun readTag(tag: Any): RawCard<*> =
201-
runBlocking {
202-
when (tag) {
203-
is NFCFeliCaTagProtocol -> readFelicaTag(tag)
204-
is NFCMiFareTagProtocol -> readMiFareTag(tag)
205-
is NFCISO15693TagProtocol -> readVicinityTag(tag)
206-
else -> throw Exception("Unsupported NFC tag type")
207-
}
231+
private suspend fun readTag(tag: Any): RawCard<*> =
232+
when (tag) {
233+
is NFCFeliCaTagProtocol -> readFelicaTag(tag)
234+
is NFCMiFareTagProtocol -> readMiFareTag(tag)
235+
is NFCISO15693TagProtocol -> readVicinityTag(tag)
236+
else -> throw Exception("Unsupported NFC tag type")
208237
}
209238

210239
private suspend fun readFelicaTag(tag: NFCFeliCaTagProtocol): RawCard<*> {
@@ -228,10 +257,14 @@ class IosNfcScanner : CardScanner {
228257
val tagId = tag.identifier.toByteArray()
229258
return when (tag.mifareFamily) {
230259
NFCMiFareDESFire -> {
260+
// Use DESFire native protocol directly. iOS requires AIDs to be
261+
// registered in Info.plist for ISO 7816 SELECT commands — an
262+
// unregistered AID causes Core NFC to kill the entire session.
263+
// DESFire native protocol avoids this by not sending SELECT commands.
231264
val transceiver = IosCardTransceiver(tag)
232265
transceiver.connect()
233266
try {
234-
ISO7816Dispatcher.readCard(tagId, transceiver)
267+
DesfireCardReader.readCard(tagId, transceiver)
235268
} finally {
236269
if (transceiver.isConnected) {
237270
try {

0 commit comments

Comments
 (0)