Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed .gitmodules
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -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<NfcReaderBackend> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
59 changes: 9 additions & 50 deletions app/ios/FareBot.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<group>"; };
534508E7AAA01FF336ECDC0C /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
A893E13DD60D0B10ECB49A59 /* FareBot.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FareBot.entitlements; sourceTree = "<group>"; };
E296318A4ABC8EE549B0C47E /* FareBotKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FareBotKit.framework; path = "../farebot-app/build/bin/iosSimulatorArm64/debugFramework/FareBotKit.framework"; sourceTree = "<group>"; };
E65B641D90F72BA2E1DEAFF7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
/* 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;
Expand All @@ -67,19 +39,10 @@
path = FareBot;
sourceTree = "<group>";
};
9B0F3B4C26A1726B3C4A2BE9 /* Frameworks */ = {
isa = PBXGroup;
children = (
E296318A4ABC8EE549B0C47E /* FareBotKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
E8645766090C58DFD719F43E = {
isa = PBXGroup;
children = (
35C5B4B3C4B8B2643DF5E68A /* FareBot */,
9B0F3B4C26A1726B3C4A2BE9 /* Frameworks */,
2098F79B3F3B6A526575D03F /* Products */,
);
sourceTree = "<group>";
Expand All @@ -94,8 +57,6 @@
B2007E057701C93D2F6474DC /* Build KMP Framework */,
42DDFD780701DBC1BD02AB98 /* Sources */,
5DA19835EA0C3024B2D5A4B9 /* Resources */,
E1F31206D4AE717D1E2DE8D8 /* Frameworks */,
C396E052E1BD6239F169D5D4 /* Embed Frameworks */,
);
buildRules = (
);
Expand Down Expand Up @@ -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 */

Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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 = (
Expand Down
3 changes: 0 additions & 3 deletions app/ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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")
}
}
}
Expand All @@ -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<*> {
Expand All @@ -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 {
Expand Down
Loading