From 5a82e3ca132424f305f188f57ad5eda6039ca000 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 13:39:13 -0500 Subject: [PATCH 1/4] feat: add card reading progress bottom sheet Add a ModalBottomSheet to HomeScreen that shows NFC reading progress with determinate/indeterminate progress bars. Thread onProgress callbacks through all card readers (Classic, Ultralight, FeliCa, DESFire, CEPAS, Vicinity, ISO7816) and platform scanners (Android, iOS, Web, Desktop). On iOS, progress updates the native Core NFC alert message instead. Co-Authored-By: Claude Opus 4.6 --- .../farebot/desktop/DesktopCardScanner.kt | 9 +++ .../farebot/desktop/NfcReaderBackend.kt | 1 + .../farebot/desktop/PN53xReaderBackend.kt | 25 +++--- .../farebot/desktop/PcscReaderBackend.kt | 16 ++-- .../farebot/app/core/nfc/ISO7816TagReader.kt | 3 +- .../app/feature/home/AndroidCardScanner.kt | 11 ++- .../composeResources/values/strings.xml | 1 + .../com/codebutler/farebot/shared/App.kt | 1 + .../farebot/shared/nfc/CardScanner.kt | 10 +++ .../farebot/shared/nfc/ISO7816Dispatcher.kt | 8 +- .../farebot/shared/ui/screen/HomeScreen.kt | 80 +++++++++++++++---- .../farebot/shared/ui/screen/HomeUiState.kt | 2 + .../farebot/shared/viewmodel/HomeViewModel.kt | 14 +++- .../farebot/shared/nfc/IosNfcScanner.kt | 32 +++++--- .../codebutler/farebot/web/WebCardScanner.kt | 23 ++++-- .../farebot/card/cepas/CEPASTagReader.kt | 3 +- .../farebot/card/cepas/CEPASCardReader.kt | 3 + .../farebot/card/classic/ClassicTagReader.kt | 3 +- .../farebot/card/classic/ClassicCardReader.kt | 5 +- .../farebot/card/desfire/DesfireTagReader.kt | 3 +- .../farebot/card/desfire/DesfireCardReader.kt | 7 +- .../farebot/card/felica/FelicaTagReader.kt | 3 +- .../farebot/card/felica/FeliCaReader.kt | 2 + .../farebot/card/iso7816/ISO7816CardReader.kt | 4 +- .../com/codebutler/farebot/card/TagReader.kt | 5 +- .../card/ultralight/UltralightTagReader.kt | 3 +- .../card/ultralight/UltralightCardReader.kt | 2 + .../card/vicinity/VicinityTagReader.kt | 1 + .../card/vicinity/VicinityCardReader.kt | 1 + 29 files changed, 215 insertions(+), 66 deletions(-) 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 6031eded3..fc3e7c225 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 @@ -26,6 +26,7 @@ import com.codebutler.farebot.card.RawCard import com.codebutler.farebot.card.nfc.pn533.PN533 import com.codebutler.farebot.card.nfc.pn533.PN533Device import com.codebutler.farebot.shared.nfc.CardScanner +import com.codebutler.farebot.shared.nfc.ReadingProgress import com.codebutler.farebot.shared.nfc.ScannedTag import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -62,6 +63,9 @@ class DesktopCardScanner : CardScanner { private val _isScanning = MutableStateFlow(false) override val isScanning: StateFlow = _isScanning.asStateFlow() + private val _readingProgress = MutableStateFlow(null) + override val readingProgress: StateFlow = _readingProgress.asStateFlow() + private var scanJob: Job? = null private val scope = CoroutineScope(Dispatchers.IO) @@ -83,11 +87,16 @@ class DesktopCardScanner : CardScanner { _scannedTags.tryEmit(tag) }, onCardRead = { rawCard -> + _readingProgress.value = null _scannedCards.tryEmit(rawCard) }, onError = { error -> + _readingProgress.value = null _scanErrors.tryEmit(error) }, + onProgress = { current, total -> + _readingProgress.value = ReadingProgress(current, total) + }, ) } catch (e: Exception) { if (isActive) { 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 04605ce14..6f24123b8 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 @@ -40,5 +40,6 @@ interface NfcReaderBackend { onCardDetected: (ScannedTag) -> Unit, onCardRead: (RawCard<*>) -> Unit, onError: (Throwable) -> Unit, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ) } 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 9b273215c..4a472440d 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 @@ -62,6 +62,7 @@ abstract class PN53xReaderBackend( onCardDetected: (ScannedTag) -> Unit, onCardRead: (RawCard<*>) -> Unit, onError: (Throwable) -> Unit, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, ) { val transport = preOpenedTransport @@ -72,7 +73,7 @@ abstract class PN53xReaderBackend( val pn533 = PN533(transport) try { initDevice(pn533) - pollLoop(pn533, onCardDetected, onCardRead, onError) + pollLoop(pn533, onCardDetected, onCardRead, onError, onProgress) } finally { pn533.close() } @@ -83,6 +84,7 @@ abstract class PN53xReaderBackend( onCardDetected: (ScannedTag) -> Unit, onCardRead: (RawCard<*>) -> Unit, onError: (Throwable) -> Unit, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, ) { while (true) { println("[$name] Polling for cards...") @@ -118,7 +120,7 @@ abstract class PN53xReaderBackend( onCardDetected(ScannedTag(id = tagId, techList = listOf(cardTypeName))) try { - val rawCard = readTarget(pn533, target) + val rawCard = readTarget(pn533, target, onProgress) onCardRead(rawCard) println("[$name] Card read successfully") } catch (e: Exception) { @@ -141,15 +143,17 @@ abstract class PN53xReaderBackend( private suspend fun readTarget( pn533: PN533, target: PN533.TargetInfo, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, ): RawCard<*> = when (target) { - is PN533.TargetInfo.TypeA -> readTypeACard(pn533, target) - is PN533.TargetInfo.FeliCa -> readFeliCaCard(pn533, target) + is PN533.TargetInfo.TypeA -> readTypeACard(pn533, target, onProgress) + is PN533.TargetInfo.FeliCa -> readFeliCaCard(pn533, target, onProgress) } private suspend fun readTypeACard( pn533: PN533, target: PN533.TargetInfo.TypeA, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, ): RawCard<*> { val info = PN533CardInfo.fromTypeA(target) val tagId = target.uid @@ -158,27 +162,27 @@ abstract class PN53xReaderBackend( return when (info.cardType) { CardType.MifareDesfire, CardType.ISO7816 -> { val transceiver = createTransceiver(pn533, target.tg) - ISO7816Dispatcher.readCard(tagId, transceiver) + ISO7816Dispatcher.readCard(tagId, transceiver, onProgress) } CardType.MifareClassic -> { val tech = PN533ClassicTechnology(pn533, target.tg, tagId, info) - ClassicCardReader.readCard(tagId, tech, null) + ClassicCardReader.readCard(tagId, tech, null, onProgress = onProgress) } CardType.MifareUltralight -> { val tech = PN533UltralightTechnology(pn533, target.tg, info) - UltralightCardReader.readCard(tagId, tech) + UltralightCardReader.readCard(tagId, tech, onProgress) } CardType.CEPAS -> { val transceiver = createTransceiver(pn533, target.tg) - CEPASCardReader.readCard(tagId, transceiver) + CEPASCardReader.readCard(tagId, transceiver, onProgress) } else -> { val transceiver = createTransceiver(pn533, target.tg) - ISO7816Dispatcher.readCard(tagId, transceiver) + ISO7816Dispatcher.readCard(tagId, transceiver, onProgress) } } } @@ -186,11 +190,12 @@ abstract class PN53xReaderBackend( private suspend fun readFeliCaCard( pn533: PN533, target: PN533.TargetInfo.FeliCa, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, ): RawCard<*> { val tagId = target.idm println("[$name] FeliCa card: IDm=${tagId.hex()}") val adapter = PN533FeliCaTagAdapter(pn533, target.idm) - return FeliCaReader.readTag(tagId, adapter) + return FeliCaReader.readTag(tagId, adapter, onProgress = onProgress) } private suspend fun waitForRemoval(pn533: PN533) { 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 07000aa47..37240c486 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 @@ -54,6 +54,7 @@ class PcscReaderBackend : NfcReaderBackend { onCardDetected: (ScannedTag) -> Unit, onCardRead: (RawCard<*>) -> Unit, onError: (Throwable) -> Unit, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, ) { val factory = TerminalFactory.getDefault() val terminals = @@ -95,7 +96,7 @@ class PcscReaderBackend : NfcReaderBackend { println("[PC/SC] Tag ID: ${tagId.hex()}") onCardDetected(ScannedTag(id = tagId, techList = listOf(info.cardType.name))) - val rawCard = readCard(info, channel, tagId) + val rawCard = readCard(info, channel, tagId, onProgress) onCardRead(rawCard) println("[PC/SC] Card read successfully") } finally { @@ -118,31 +119,32 @@ class PcscReaderBackend : NfcReaderBackend { info: PCSCCardInfo, channel: javax.smartcardio.CardChannel, tagId: ByteArray, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, ): RawCard<*> = when (info.cardType) { CardType.MifareDesfire, CardType.ISO7816 -> { val transceiver = PCSCCardTransceiver(channel) - ISO7816Dispatcher.readCard(tagId, transceiver) + ISO7816Dispatcher.readCard(tagId, transceiver, onProgress) } CardType.MifareClassic -> { val tech = PCSCClassicTechnology(channel, info) - ClassicCardReader.readCard(tagId, tech, null) + ClassicCardReader.readCard(tagId, tech, null, onProgress = onProgress) } CardType.MifareUltralight -> { val tech = PCSCUltralightTechnology(channel, info) - UltralightCardReader.readCard(tagId, tech) + UltralightCardReader.readCard(tagId, tech, onProgress) } CardType.FeliCa -> { val adapter = PCSCFeliCaTagAdapter(channel) - FeliCaReader.readTag(tagId, adapter) + FeliCaReader.readTag(tagId, adapter, onProgress = onProgress) } CardType.CEPAS -> { val transceiver = PCSCCardTransceiver(channel) - CEPASCardReader.readCard(tagId, transceiver) + CEPASCardReader.readCard(tagId, transceiver, onProgress) } CardType.Vicinity -> { @@ -152,7 +154,7 @@ class PcscReaderBackend : NfcReaderBackend { else -> { val transceiver = PCSCCardTransceiver(channel) - ISO7816Dispatcher.readCard(tagId, transceiver) + ISO7816Dispatcher.readCard(tagId, transceiver, onProgress) } } diff --git a/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/nfc/ISO7816TagReader.kt b/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/nfc/ISO7816TagReader.kt index 0de5437ba..57b36bf19 100644 --- a/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/nfc/ISO7816TagReader.kt +++ b/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/nfc/ISO7816TagReader.kt @@ -48,5 +48,6 @@ class ISO7816TagReader( tag: Tag, tech: CardTransceiver, cardKeys: CardKeys?, - ): RawCard<*> = ISO7816Dispatcher.readCard(tagId, tech) + onProgress: (suspend (current: Int, total: Int) -> Unit)?, + ): RawCard<*> = ISO7816Dispatcher.readCard(tagId, tech, onProgress) } diff --git a/app/src/androidMain/kotlin/com/codebutler/farebot/app/feature/home/AndroidCardScanner.kt b/app/src/androidMain/kotlin/com/codebutler/farebot/app/feature/home/AndroidCardScanner.kt index 4f8f816cb..d91cddc6d 100644 --- a/app/src/androidMain/kotlin/com/codebutler/farebot/app/feature/home/AndroidCardScanner.kt +++ b/app/src/androidMain/kotlin/com/codebutler/farebot/app/feature/home/AndroidCardScanner.kt @@ -10,6 +10,7 @@ import com.codebutler.farebot.key.CardKeys import com.codebutler.farebot.persist.CardKeysPersister import com.codebutler.farebot.shared.nfc.CardScanner import com.codebutler.farebot.shared.nfc.CardUnauthorizedException +import com.codebutler.farebot.shared.nfc.ReadingProgress import com.codebutler.farebot.shared.nfc.ScannedTag import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -51,6 +52,9 @@ class AndroidCardScanner( private val _isScanning = MutableStateFlow(false) override val isScanning: StateFlow = _isScanning.asStateFlow() + private val _readingProgress = MutableStateFlow(null) + override val readingProgress: StateFlow = _readingProgress.asStateFlow() + private var isObserving = false fun startObservingTags() { @@ -65,12 +69,17 @@ class AndroidCardScanner( _isScanning.value = true try { val cardKeys = getCardKeys(ByteUtils.getHexString(tag.id)) - val rawCard = tagReaderFactory.getTagReader(tag.id, tag, cardKeys).readTag() + val rawCard = + tagReaderFactory.getTagReader(tag.id, tag, cardKeys).readTag { current, total -> + _readingProgress.value = ReadingProgress(current, total) + } + _readingProgress.value = null if (rawCard.isUnauthorized()) { throw CardUnauthorizedException(rawCard.tagId(), rawCard.cardType()) } _scannedCards.emit(rawCard) } catch (error: Throwable) { + _readingProgress.value = null _scanErrors.emit(error) } finally { _isScanning.value = false diff --git a/app/src/commonMain/composeResources/values/strings.xml b/app/src/commonMain/composeResources/values/strings.xml index b53891c4e..0d1b71628 100644 --- a/app/src/commonMain/composeResources/values/strings.xml +++ b/app/src/commonMain/composeResources/values/strings.xml @@ -80,6 +80,7 @@ Explore Scan Reading\u2026 + Hold your card near the reader Search Enable NFC to scan cards diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt index be0b18ff3..758c0c319 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt @@ -152,6 +152,7 @@ fun FareBotApp( navController.navigate(Screen.AddKey.createRoute(tagId, cardType)) }, onScanCard = { homeViewModel.startActiveScan() }, + onCancelScan = { homeViewModel.stopActiveScan() }, historyUiState = historyUiState, onNavigateToCard = { itemId -> val cardKey = historyViewModel.getCardNavKey(itemId) diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/CardScanner.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/CardScanner.kt index 80d53c45d..ad141f1b7 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/CardScanner.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/CardScanner.kt @@ -24,9 +24,15 @@ package com.codebutler.farebot.shared.nfc import com.codebutler.farebot.card.RawCard import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +data class ReadingProgress( + val current: Int, + val total: Int, +) + data class ScannedTag( val id: ByteArray, val techList: List, @@ -66,6 +72,10 @@ interface CardScanner { /** Whether scanning is currently in progress. */ val isScanning: StateFlow + /** Reading progress — non-null when a card read is in progress with known total. */ + val readingProgress: StateFlow + get() = MutableStateFlow(null) + /** * Start an active scan session (e.g., iOS NFC dialog). * Results are emitted to [scannedCards]. diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/ISO7816Dispatcher.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/ISO7816Dispatcher.kt index 08965a503..79f5aaee9 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/ISO7816Dispatcher.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/ISO7816Dispatcher.kt @@ -39,23 +39,25 @@ object ISO7816Dispatcher { suspend fun readCard( tagId: ByteArray, transceiver: CardTransceiver, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): RawCard<*> { - val iso7816Card = tryISO7816(tagId, transceiver) + val iso7816Card = tryISO7816(tagId, transceiver, onProgress) if (iso7816Card != null) { return iso7816Card } - return DesfireCardReader.readCard(tagId, transceiver) + return DesfireCardReader.readCard(tagId, transceiver, onProgress) } private suspend fun tryISO7816( tagId: ByteArray, transceiver: CardTransceiver, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): RawCard<*>? { val appConfigs = buildAppConfigs() if (appConfigs.isEmpty()) return null return try { - ISO7816CardReader.readCard(tagId, transceiver, appConfigs) + ISO7816CardReader.readCard(tagId, transceiver, appConfigs, onProgress) } catch (e: Exception) { println("[ISO7816Dispatcher] ISO7816 read attempt failed: $e") null diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt index e9a1b4d69..1f1eee84c 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -42,7 +43,6 @@ import androidx.compose.material.icons.filled.VpnKey import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -50,6 +50,7 @@ import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -57,6 +58,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.PermanentDrawerSheet import androidx.compose.material3.PermanentNavigationDrawer import androidx.compose.material3.Scaffold @@ -107,6 +109,7 @@ import farebot.app.generated.resources.app_name import farebot.app.generated.resources.cancel import farebot.app.generated.resources.delete import farebot.app.generated.resources.delete_selected_cards +import farebot.app.generated.resources.hold_card_near_reader import farebot.app.generated.resources.ic_cards_stack import farebot.app.generated.resources.ic_launcher import farebot.app.generated.resources.import_source @@ -148,6 +151,7 @@ fun HomeScreen( onDismissError: () -> Unit, onNavigateToAddKeyForCard: (tagId: String, cardType: CardType) -> Unit, onScanCard: () -> Unit, + onCancelScan: () -> Unit, historyUiState: HistoryUiState, onNavigateToCard: (String) -> Unit, onImportFile: () -> Unit, @@ -658,22 +662,10 @@ fun HomeScreen( } }, icon = { - if (homeUiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.padding(4.dp), - color = MaterialTheme.colorScheme.onPrimaryContainer, - strokeWidth = 2.dp, - ) - } else { - Icon(Icons.Default.Nfc, contentDescription = null) - } + Icon(Icons.Default.Nfc, contentDescription = null) }, text = { - Text( - stringResource( - if (homeUiState.isReadingCard) Res.string.reading_card else Res.string.scan, - ), - ) + Text(stringResource(Res.string.scan)) }, ) } @@ -867,6 +859,64 @@ fun HomeScreen( } } + // Reading progress bottom sheet — shown while scanning/reading a card + if (homeUiState.isLoading || homeUiState.isReadingCard) { + ModalBottomSheet( + onDismissRequest = onCancelScan, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Default.Nfc, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Text( + text = + if (homeUiState.isReadingCard) { + stringResource(Res.string.reading_card) + } else { + stringResource(Res.string.hold_card_near_reader) + }, + style = MaterialTheme.typography.titleMedium, + ) + + val progress = homeUiState.readingProgress + if (progress != null) { + LinearProgressIndicator( + progress = { progress.current.toFloat() / progress.total.toFloat() }, + modifier = Modifier.fillMaxWidth().height(4.dp), + ) + Text( + text = "${progress.current} / ${progress.total}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth().height(4.dp), + ) + } + + if (homeUiState.requiresActiveScan) { + OutlinedButton(onClick = onCancelScan) { + Text(stringResource(Res.string.cancel)) + } + } + } + } + } + if (showImportSheet) { ModalBottomSheet( onDismissRequest = { showImportSheet = false }, diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeUiState.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeUiState.kt index 4fbaf35ce..fc07f3a02 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeUiState.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeUiState.kt @@ -1,5 +1,6 @@ package com.codebutler.farebot.shared.ui.screen +import com.codebutler.farebot.shared.nfc.ReadingProgress import com.codebutler.farebot.shared.platform.NfcStatus data class HomeUiState( @@ -7,4 +8,5 @@ data class HomeUiState( val isLoading: Boolean = false, val isReadingCard: Boolean = false, val requiresActiveScan: Boolean = true, + val readingProgress: ReadingProgress? = null, ) diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HomeViewModel.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HomeViewModel.kt index c84e88fdd..d0a09bb3f 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HomeViewModel.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HomeViewModel.kt @@ -82,16 +82,22 @@ class HomeViewModel( } } + viewModelScope.launch { + cardScanner.readingProgress.collect { progress -> + _uiState.value = _uiState.value.copy(readingProgress = progress) + } + } + viewModelScope.launch { cardScanner.scannedCards.collect { rawCard -> - _uiState.value = _uiState.value.copy(isReadingCard = false) + _uiState.value = _uiState.value.copy(isReadingCard = false, readingProgress = null) processScannedCard(rawCard) } } viewModelScope.launch { cardScanner.scanErrors.collect { error -> - _uiState.value = _uiState.value.copy(isReadingCard = false) + _uiState.value = _uiState.value.copy(isReadingCard = false, readingProgress = null) val scanError = categorizeError(error) analytics.logEvent( "scan_card_error", @@ -109,6 +115,10 @@ class HomeViewModel( cardScanner?.startActiveScan() } + fun stopActiveScan() { + cardScanner?.stopActiveScan() + } + fun dismissError() { _errorMessage.value = null } 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 e11c808ab..2fe6a7aa0 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 @@ -185,7 +185,10 @@ class IosNfcScanner : CardScanner { CoroutineScope(Dispatchers.IO).launch { try { - rawCard = readTag(tag) + rawCard = + readTag(tag) { current, total -> + session.alertMessage = "Reading… $current / $total" + } } catch (e: Exception) { readException = e } finally { @@ -228,15 +231,21 @@ class IosNfcScanner : CardScanner { override fun tagReaderSessionDidBecomeActive(session: NFCTagReaderSession) { } - private suspend fun readTag(tag: Any): RawCard<*> = + private suspend fun readTag( + tag: Any, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, + ): RawCard<*> = when (tag) { - is NFCFeliCaTagProtocol -> readFelicaTag(tag) - is NFCMiFareTagProtocol -> readMiFareTag(tag) + is NFCFeliCaTagProtocol -> readFelicaTag(tag, onProgress) + is NFCMiFareTagProtocol -> readMiFareTag(tag, onProgress) is NFCISO15693TagProtocol -> readVicinityTag(tag) else -> throw Exception("Unsupported NFC tag type") } - private suspend fun readFelicaTag(tag: NFCFeliCaTagProtocol): RawCard<*> { + private suspend fun readFelicaTag( + tag: NFCFeliCaTagProtocol, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, + ): RawCard<*> { val tagId = tag.currentIDm.toByteArray() /* * onlyFirst = true is an iOS-specific hack to work around @@ -250,10 +259,13 @@ class IosNfcScanner : CardScanner { * * Once iOS fixes this, do an iOS version check instead. */ - return FeliCaReader.readTag(tagId, IosFeliCaTagAdapter(tag), onlyFirst = true) + return FeliCaReader.readTag(tagId, IosFeliCaTagAdapter(tag), onlyFirst = true, onProgress = onProgress) } - private suspend fun readMiFareTag(tag: NFCMiFareTagProtocol): RawCard<*> { + private suspend fun readMiFareTag( + tag: NFCMiFareTagProtocol, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, + ): RawCard<*> { val tagId = tag.identifier.toByteArray() return when (tag.mifareFamily) { NFCMiFareDESFire -> { @@ -264,7 +276,7 @@ class IosNfcScanner : CardScanner { val transceiver = IosCardTransceiver(tag) transceiver.connect() try { - DesfireCardReader.readCard(tagId, transceiver) + DesfireCardReader.readCard(tagId, transceiver, onProgress) } finally { if (transceiver.isConnected) { try { @@ -279,7 +291,7 @@ class IosNfcScanner : CardScanner { val tech = IosUltralightTechnology(tag) tech.connect() try { - UltralightCardReader.readCard(tagId, tech) + UltralightCardReader.readCard(tagId, tech, onProgress) } finally { if (tech.isConnected) { try { @@ -295,7 +307,7 @@ class IosNfcScanner : CardScanner { val transceiver = IosCardTransceiver(tag) transceiver.connect() try { - CEPASCardReader.readCard(tagId, transceiver) + CEPASCardReader.readCard(tagId, transceiver, onProgress) } 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 986de24d6..960cabd41 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 @@ -16,6 +16,7 @@ import com.codebutler.farebot.card.nfc.pn533.WebUsbPN533Transport import com.codebutler.farebot.card.ultralight.UltralightCardReader import com.codebutler.farebot.shared.nfc.CardScanner import com.codebutler.farebot.shared.nfc.ISO7816Dispatcher +import com.codebutler.farebot.shared.nfc.ReadingProgress import com.codebutler.farebot.shared.nfc.ScannedTag import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -60,6 +61,9 @@ class WebCardScanner : CardScanner { private val _isScanning = MutableStateFlow(false) override val isScanning: StateFlow = _isScanning.asStateFlow() + private val _readingProgress = MutableStateFlow(null) + override val readingProgress: StateFlow = _readingProgress.asStateFlow() + private var scanJob: Job? = null private var transport: WebUsbPN533Transport? = null private val scope = CoroutineScope(SupervisorJob()) @@ -108,6 +112,7 @@ class WebCardScanner : CardScanner { transport?.close() transport = null _isScanning.value = false + _readingProgress.value = null } private suspend fun pollLoop(transport: WebUsbPN533Transport) { @@ -157,9 +162,11 @@ class WebCardScanner : CardScanner { try { val rawCard = readTarget(pn533, target) + _readingProgress.value = null _scannedCards.tryEmit(rawCard) println("[WebUSB] Card read successfully") } catch (e: Exception) { + _readingProgress.value = null println("[WebUSB] Read error: ${e.message}") _scanErrors.tryEmit(e) } @@ -185,6 +192,10 @@ class WebCardScanner : CardScanner { is PN533.TargetInfo.FeliCa -> readFeliCaCard(pn533, target) } + private val onProgress: suspend (Int, Int) -> Unit = { current, total -> + _readingProgress.value = ReadingProgress(current, total) + } + private suspend fun readTypeACard( pn533: PN533, target: PN533.TargetInfo.TypeA, @@ -200,27 +211,27 @@ class WebCardScanner : CardScanner { return when (info.cardType) { CardType.MifareDesfire, CardType.ISO7816 -> { val transceiver = PN533CardTransceiver(pn533, target.tg) - ISO7816Dispatcher.readCard(tagId, transceiver) + ISO7816Dispatcher.readCard(tagId, transceiver, onProgress) } CardType.MifareClassic -> { val tech = PN533ClassicTechnology(pn533, target.tg, tagId, info) - ClassicCardReader.readCard(tagId, tech, null) + ClassicCardReader.readCard(tagId, tech, null, onProgress = onProgress) } CardType.MifareUltralight -> { val tech = PN533UltralightTechnology(pn533, target.tg, info) - UltralightCardReader.readCard(tagId, tech) + UltralightCardReader.readCard(tagId, tech, onProgress) } CardType.CEPAS -> { val transceiver = PN533CardTransceiver(pn533, target.tg) - CEPASCardReader.readCard(tagId, transceiver) + CEPASCardReader.readCard(tagId, transceiver, onProgress) } else -> { val transceiver = PN533CardTransceiver(pn533, target.tg) - ISO7816Dispatcher.readCard(tagId, transceiver) + ISO7816Dispatcher.readCard(tagId, transceiver, onProgress) } } } @@ -232,7 +243,7 @@ class WebCardScanner : CardScanner { val tagId = target.idm println("[WebUSB] FeliCa card: IDm=${tagId.hex()}") val adapter = PN533FeliCaTagAdapter(pn533, tagId) - return FeliCaReader.readTag(tagId, adapter) + return FeliCaReader.readTag(tagId, adapter, onProgress = onProgress) } private suspend fun waitForRemoval(pn533: PN533) { diff --git a/card/cepas/src/androidMain/kotlin/com/codebutler/farebot/card/cepas/CEPASTagReader.kt b/card/cepas/src/androidMain/kotlin/com/codebutler/farebot/card/cepas/CEPASTagReader.kt index 68316666d..b15a7455a 100644 --- a/card/cepas/src/androidMain/kotlin/com/codebutler/farebot/card/cepas/CEPASTagReader.kt +++ b/card/cepas/src/androidMain/kotlin/com/codebutler/farebot/card/cepas/CEPASTagReader.kt @@ -42,5 +42,6 @@ class CEPASTagReader( tag: Tag, tech: CardTransceiver, cardKeys: CardKeys?, - ): RawCEPASCard = CEPASCardReader.readCard(tagId, tech) + onProgress: (suspend (current: Int, total: Int) -> Unit)?, + ): RawCEPASCard = CEPASCardReader.readCard(tagId, tech, onProgress) } diff --git a/card/cepas/src/commonMain/kotlin/com/codebutler/farebot/card/cepas/CEPASCardReader.kt b/card/cepas/src/commonMain/kotlin/com/codebutler/farebot/card/cepas/CEPASCardReader.kt index bcf20c6b8..8f3dd840c 100644 --- a/card/cepas/src/commonMain/kotlin/com/codebutler/farebot/card/cepas/CEPASCardReader.kt +++ b/card/cepas/src/commonMain/kotlin/com/codebutler/farebot/card/cepas/CEPASCardReader.kt @@ -34,6 +34,7 @@ object CEPASCardReader { suspend fun readCard( tagId: ByteArray, tech: CardTransceiver, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): RawCEPASCard { val purses = arrayOfNulls(16) val histories = arrayOfNulls(16) @@ -41,6 +42,7 @@ object CEPASCardReader { val protocol = CEPASProtocol(ISO7816Protocol(tech)) for (purseId in purses.indices) { + onProgress?.invoke(purseId, 32) val purseData = protocol.getPurse(purseId) purses[purseId] = if (purseData != null) { @@ -51,6 +53,7 @@ object CEPASCardReader { } for (historyId in histories.indices) { + onProgress?.invoke(16 + historyId, 32) val rawCEPASPurse = purses[historyId]!! if (rawCEPASPurse.isValid) { val historyData = protocol.getHistory(historyId) diff --git a/card/classic/src/androidMain/kotlin/com/codebutler/farebot/card/classic/ClassicTagReader.kt b/card/classic/src/androidMain/kotlin/com/codebutler/farebot/card/classic/ClassicTagReader.kt index 03ebbe197..65eadc90f 100644 --- a/card/classic/src/androidMain/kotlin/com/codebutler/farebot/card/classic/ClassicTagReader.kt +++ b/card/classic/src/androidMain/kotlin/com/codebutler/farebot/card/classic/ClassicTagReader.kt @@ -44,5 +44,6 @@ class ClassicTagReader( tag: Tag, tech: ClassicTechnology, cardKeys: ClassicCardKeys?, - ): RawClassicCard = ClassicCardReader.readCard(tagId, tech, cardKeys) + onProgress: (suspend (current: Int, total: Int) -> Unit)?, + ): RawClassicCard = ClassicCardReader.readCard(tagId, tech, cardKeys, onProgress = onProgress) } diff --git a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt index 9c88513b4..831579fa1 100644 --- a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt +++ b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt @@ -49,10 +49,13 @@ object ClassicCardReader { tech: ClassicTechnology, cardKeys: ClassicCardKeys?, globalKeys: List? = null, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): RawClassicCard { val sectors = ArrayList() + val sectorCount = tech.sectorCount - for (sectorIndex in 0 until tech.sectorCount) { + for (sectorIndex in 0 until sectorCount) { + onProgress?.invoke(sectorIndex, sectorCount) try { var authSuccess = false var successfulKey: ByteArray? = null diff --git a/card/desfire/src/androidMain/kotlin/com/codebutler/farebot/card/desfire/DesfireTagReader.kt b/card/desfire/src/androidMain/kotlin/com/codebutler/farebot/card/desfire/DesfireTagReader.kt index 898eac857..90c6d0e5c 100644 --- a/card/desfire/src/androidMain/kotlin/com/codebutler/farebot/card/desfire/DesfireTagReader.kt +++ b/card/desfire/src/androidMain/kotlin/com/codebutler/farebot/card/desfire/DesfireTagReader.kt @@ -42,5 +42,6 @@ class DesfireTagReader( tag: Tag, tech: CardTransceiver, cardKeys: CardKeys?, - ): RawDesfireCard = DesfireCardReader.readCard(tagId, tech) + onProgress: (suspend (current: Int, total: Int) -> Unit)?, + ): RawDesfireCard = DesfireCardReader.readCard(tagId, tech, onProgress) } diff --git a/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireCardReader.kt b/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireCardReader.kt index 33db1135a..ec2ad2d0c 100644 --- a/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireCardReader.kt +++ b/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireCardReader.kt @@ -34,6 +34,7 @@ object DesfireCardReader { suspend fun readCard( tagId: ByteArray, tech: CardTransceiver, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): RawDesfireCard { val desfireProtocol = DesfireProtocol(tech) @@ -53,7 +54,7 @@ object DesfireCardReader { intArrayOf() } - val apps = readApplications(desfireProtocol, appIds) + val apps = readApplications(desfireProtocol, appIds, onProgress) return RawDesfireCard.create(tagId, Clock.System.now(), apps, manufData, appListLocked) } @@ -61,6 +62,7 @@ object DesfireCardReader { private suspend fun readApplications( desfireProtocol: DesfireProtocol, appIds: IntArray, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): List { val apps = ArrayList() @@ -70,7 +72,8 @@ object DesfireCardReader { // - Use unlocker.getOrder() to reorder file IDs // - Call unlocker.unlock() before reading each file - for (appId in appIds) { + for ((appIndex, appId) in appIds.withIndex()) { + onProgress?.invoke(appIndex, appIds.size) try { desfireProtocol.selectApp(appId) } catch (e: NotFoundException) { diff --git a/card/felica/src/androidMain/kotlin/com/codebutler/farebot/card/felica/FelicaTagReader.kt b/card/felica/src/androidMain/kotlin/com/codebutler/farebot/card/felica/FelicaTagReader.kt index db7fefb7d..d50a5a360 100644 --- a/card/felica/src/androidMain/kotlin/com/codebutler/farebot/card/felica/FelicaTagReader.kt +++ b/card/felica/src/androidMain/kotlin/com/codebutler/farebot/card/felica/FelicaTagReader.kt @@ -43,8 +43,9 @@ class FelicaTagReader( tag: Tag, tech: NfcFTechnology, cardKeys: CardKeys?, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, ): RawFelicaCard { val adapter = AndroidFeliCaTagAdapter(tag) - return FeliCaReader.readTag(tagId, adapter) + return FeliCaReader.readTag(tagId, adapter, onProgress = onProgress) } } diff --git a/card/felica/src/commonMain/kotlin/com/codebutler/farebot/card/felica/FeliCaReader.kt b/card/felica/src/commonMain/kotlin/com/codebutler/farebot/card/felica/FeliCaReader.kt index 37c74aec4..8021999d3 100644 --- a/card/felica/src/commonMain/kotlin/com/codebutler/farebot/card/felica/FeliCaReader.kt +++ b/card/felica/src/commonMain/kotlin/com/codebutler/farebot/card/felica/FeliCaReader.kt @@ -45,6 +45,7 @@ object FeliCaReader { tagId: ByteArray, adapter: FeliCaTagAdapter, onlyFirst: Boolean = false, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): RawFelicaCard { val idmBytes = adapter.getIDm() val idm = FeliCaIdm(idmBytes) @@ -85,6 +86,7 @@ object FeliCaReader { val systems = mutableListOf() for ((systemNumber, systemCode) in systemCodes.withIndex()) { + onProgress?.invoke(systemNumber, systemCodes.size) // We can get System Code 0 from magic fallbacks -- drop this. if (systemCode == 0) continue diff --git a/card/iso7816/src/commonMain/kotlin/com/codebutler/farebot/card/iso7816/ISO7816CardReader.kt b/card/iso7816/src/commonMain/kotlin/com/codebutler/farebot/card/iso7816/ISO7816CardReader.kt index 6bae932de..bc3d3ca39 100644 --- a/card/iso7816/src/commonMain/kotlin/com/codebutler/farebot/card/iso7816/ISO7816CardReader.kt +++ b/card/iso7816/src/commonMain/kotlin/com/codebutler/farebot/card/iso7816/ISO7816CardReader.kt @@ -67,11 +67,13 @@ object ISO7816CardReader { tagId: ByteArray, transceiver: CardTransceiver, appConfigs: List, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): RawISO7816Card? { val protocol = ISO7816Protocol(transceiver) val applications = mutableListOf() - for (config in appConfigs) { + for ((configIndex, config) in appConfigs.withIndex()) { + onProgress?.invoke(configIndex, appConfigs.size) val app = tryReadApplication(protocol, config) ?: continue applications.add(app) } diff --git a/card/src/androidMain/kotlin/com/codebutler/farebot/card/TagReader.kt b/card/src/androidMain/kotlin/com/codebutler/farebot/card/TagReader.kt index 1336a98da..0ad6d0d8b 100644 --- a/card/src/androidMain/kotlin/com/codebutler/farebot/card/TagReader.kt +++ b/card/src/androidMain/kotlin/com/codebutler/farebot/card/TagReader.kt @@ -36,11 +36,11 @@ abstract class TagReader< private val mCardKeys: K?, ) { @Throws(Exception::class) - suspend fun readTag(): C { + suspend fun readTag(onProgress: (suspend (current: Int, total: Int) -> Unit)? = null): C { val tech = getTech(mTag) try { tech.connect() - return readTag(mTagId, mTag, tech, mCardKeys) + return readTag(mTagId, mTag, tech, mCardKeys, onProgress) } finally { if (tech.isConnected) { try { @@ -58,6 +58,7 @@ abstract class TagReader< tag: Tag, tech: T, cardKeys: K?, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): C protected abstract fun getTech(tag: Tag): T diff --git a/card/ultralight/src/androidMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightTagReader.kt b/card/ultralight/src/androidMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightTagReader.kt index 213fa6adb..95697f466 100644 --- a/card/ultralight/src/androidMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightTagReader.kt +++ b/card/ultralight/src/androidMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightTagReader.kt @@ -42,5 +42,6 @@ class UltralightTagReader( tag: Tag, tech: UltralightTechnology, cardKeys: CardKeys?, - ): RawUltralightCard = UltralightCardReader.readCard(tagId, tech) + onProgress: (suspend (current: Int, total: Int) -> Unit)?, + ): RawUltralightCard = UltralightCardReader.readCard(tagId, tech, onProgress) } diff --git a/card/ultralight/src/commonMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightCardReader.kt b/card/ultralight/src/commonMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightCardReader.kt index 08440a1e2..c4179058e 100644 --- a/card/ultralight/src/commonMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightCardReader.kt +++ b/card/ultralight/src/commonMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightCardReader.kt @@ -31,6 +31,7 @@ object UltralightCardReader { suspend fun readCard( tagId: ByteArray, tech: UltralightTechnology, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): RawUltralightCard { // Detect card type using protocol commands (GET_VERSION, AUTH_1) val detectedType = detectCardType(tech) @@ -56,6 +57,7 @@ object UltralightCardReader { val pages = mutableListOf() var unauthorized = false while (pageNumber < size) { + onProgress?.invoke(pageNumber, size) if (pageNumber % 4 == 0) { try { pageBuffer = tech.readPages(pageNumber) diff --git a/card/vicinity/src/androidMain/kotlin/com/codebutler/farebot/card/vicinity/VicinityTagReader.kt b/card/vicinity/src/androidMain/kotlin/com/codebutler/farebot/card/vicinity/VicinityTagReader.kt index a603bafcc..9c50c44ee 100644 --- a/card/vicinity/src/androidMain/kotlin/com/codebutler/farebot/card/vicinity/VicinityTagReader.kt +++ b/card/vicinity/src/androidMain/kotlin/com/codebutler/farebot/card/vicinity/VicinityTagReader.kt @@ -48,5 +48,6 @@ class VicinityTagReader( tag: Tag, tech: VicinityTechnology, cardKeys: CardKeys?, + onProgress: (suspend (current: Int, total: Int) -> Unit)?, ): RawVicinityCard = VicinityCardReader.readCard(tagId, tech) } diff --git a/card/vicinity/src/commonMain/kotlin/com/codebutler/farebot/card/vicinity/VicinityCardReader.kt b/card/vicinity/src/commonMain/kotlin/com/codebutler/farebot/card/vicinity/VicinityCardReader.kt index 785f10b3c..f4cad34c5 100644 --- a/card/vicinity/src/commonMain/kotlin/com/codebutler/farebot/card/vicinity/VicinityCardReader.kt +++ b/card/vicinity/src/commonMain/kotlin/com/codebutler/farebot/card/vicinity/VicinityCardReader.kt @@ -48,6 +48,7 @@ object VicinityCardReader { suspend fun readCard( tagId: ByteArray, tech: VicinityTechnology, + onProgress: (suspend (current: Int, total: Int) -> Unit)? = null, ): RawVicinityCard { val uid = tech.uid From 36efbb1b37299aa4180b02951451cebd19437089 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 13:43:51 -0500 Subject: [PATCH 2/4] fix: add null guards for WebUSB device and console error logging - Add null checks for window._fbUsb.device before transferOut/transferIn calls to prevent JsException when device is disconnected mid-scan - Add console logging (println + printStackTrace) for scan errors and card processing errors in HomeViewModel so they appear in DevTools Co-Authored-By: Claude Opus 4.6 --- .../codebutler/farebot/shared/viewmodel/HomeViewModel.kt | 4 ++++ .../farebot/card/nfc/pn533/WebUsbPN533Transport.kt | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HomeViewModel.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HomeViewModel.kt index d0a09bb3f..6c4fb4313 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HomeViewModel.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HomeViewModel.kt @@ -98,6 +98,8 @@ class HomeViewModel( viewModelScope.launch { cardScanner.scanErrors.collect { error -> _uiState.value = _uiState.value.copy(isReadingCard = false, readingProgress = null) + println("[FareBot] Scan error: ${error::class.simpleName}: ${error.message}") + error.printStackTrace() val scanError = categorizeError(error) analytics.logEvent( "scan_card_error", @@ -165,6 +167,8 @@ class HomeViewModel( val key = navDataHolder.put(rawCard) _navigateToCard.emit(key) } catch (e: Exception) { + println("[FareBot] Process card error: ${e::class.simpleName}: ${e.message}") + e.printStackTrace() _errorMessage.value = ScanError( title = getString(Res.string.error), 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 141fd106e..9d20b5d63 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 @@ -286,6 +286,11 @@ private fun jsWebUsbStartTransferOut(dataStr: JsString) { """ (function() { window._fbUsbOut = { ready: false, error: null }; + if (!window._fbUsb || !window._fbUsb.device) { + window._fbUsbOut.error = 'Device not connected'; + window._fbUsbOut.ready = true; + return; + } var parts = dataStr.split(','); var bytes = new Uint8Array(parts.length); for (var i = 0; i < parts.length; i++) bytes[i] = parseInt(parts[i]); @@ -309,6 +314,10 @@ private fun jsWebUsbStartTransferIn(timeoutMs: Int) { """ (function() { window._fbUsbIn = { data: null, ready: false }; + if (!window._fbUsb || !window._fbUsb.device) { + window._fbUsbIn.ready = true; + return; + } var timer = setTimeout(function() { if (!window._fbUsbIn.ready) window._fbUsbIn.ready = true; }, timeoutMs); From 31baf6481485a771a6d3eec1d27087bf99c5efb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 13:45:32 -0500 Subject: [PATCH 3/4] fix(desktop): catch UnsatisfiedLinkError when libusb is unavailable When the packaged desktop app runs without libusb installed, PN533Device.openAll() throws UnsatisfiedLinkError which was uncaught and crashed silently (no UI feedback). Now catch it gracefully and fall back to PC/SC-only scanning. Co-Authored-By: Claude Opus 4.6 --- .../com/codebutler/farebot/desktop/DesktopCardScanner.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 fc3e7c225..86d63e6ac 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 @@ -128,7 +128,13 @@ class DesktopCardScanner : CardScanner { private suspend fun discoverBackends(): List { val backends = mutableListOf(PcscReaderBackend()) - val transports = PN533Device.openAll() + val transports = + try { + PN533Device.openAll() + } catch (e: UnsatisfiedLinkError) { + println("[DesktopCardScanner] libusb not available: ${e.message}") + emptyList() + } if (transports.isEmpty()) { backends.add(PN533ReaderBackend()) } else { From 900ce5a0071c73cb4a9754f4cd4f02f13b66fc2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 13:49:38 -0500 Subject: [PATCH 4/4] fix: add console logging for card load errors in CardViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ClassCastException (UnauthorizedClassicSector → DataClassicSector) was only shown in the UI error dialog but never printed to console, making it invisible in DevTools. Add println + printStackTrace. Co-Authored-By: Claude Opus 4.6 --- .../com/codebutler/farebot/shared/viewmodel/CardViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/CardViewModel.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/CardViewModel.kt index 0247f0202..197c4bde4 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/CardViewModel.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/CardViewModel.kt @@ -154,6 +154,8 @@ class CardViewModel( ) } } catch (ex: Exception) { + println("[FareBot] Card load error: ${ex::class.simpleName}: ${ex.message}") + ex.printStackTrace() _uiState.value = CardUiState( isLoading = false,