Skip to content

Commit 2181101

Browse files
codebutlerClaudeclaude
authored
feat: add card reading progress bottom sheet (#243)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix: add console logging for card load errors in CardViewModel 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 <noreply@anthropic.com> --------- Co-authored-by: Claude <claude@codebutler.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9bc0fa2 commit 2181101

File tree

31 files changed

+237
-67
lines changed

31 files changed

+237
-67
lines changed

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import com.codebutler.farebot.card.RawCard
2626
import com.codebutler.farebot.card.nfc.pn533.PN533
2727
import com.codebutler.farebot.card.nfc.pn533.PN533Device
2828
import com.codebutler.farebot.shared.nfc.CardScanner
29+
import com.codebutler.farebot.shared.nfc.ReadingProgress
2930
import com.codebutler.farebot.shared.nfc.ScannedTag
3031
import kotlinx.coroutines.CoroutineScope
3132
import kotlinx.coroutines.Dispatchers
@@ -62,6 +63,9 @@ class DesktopCardScanner : CardScanner {
6263
private val _isScanning = MutableStateFlow(false)
6364
override val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
6465

66+
private val _readingProgress = MutableStateFlow<ReadingProgress?>(null)
67+
override val readingProgress: StateFlow<ReadingProgress?> = _readingProgress.asStateFlow()
68+
6569
private var scanJob: Job? = null
6670
private val scope = CoroutineScope(Dispatchers.IO)
6771

@@ -83,11 +87,16 @@ class DesktopCardScanner : CardScanner {
8387
_scannedTags.tryEmit(tag)
8488
},
8589
onCardRead = { rawCard ->
90+
_readingProgress.value = null
8691
_scannedCards.tryEmit(rawCard)
8792
},
8893
onError = { error ->
94+
_readingProgress.value = null
8995
_scanErrors.tryEmit(error)
9096
},
97+
onProgress = { current, total ->
98+
_readingProgress.value = ReadingProgress(current, total)
99+
},
91100
)
92101
} catch (e: Exception) {
93102
if (isActive) {
@@ -119,7 +128,13 @@ class DesktopCardScanner : CardScanner {
119128

120129
private suspend fun discoverBackends(): List<NfcReaderBackend> {
121130
val backends = mutableListOf<NfcReaderBackend>(PcscReaderBackend())
122-
val transports = PN533Device.openAll()
131+
val transports =
132+
try {
133+
PN533Device.openAll()
134+
} catch (e: UnsatisfiedLinkError) {
135+
println("[DesktopCardScanner] libusb not available: ${e.message}")
136+
emptyList()
137+
}
123138
if (transports.isEmpty()) {
124139
backends.add(PN533ReaderBackend())
125140
} else {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@ interface NfcReaderBackend {
4040
onCardDetected: (ScannedTag) -> Unit,
4141
onCardRead: (RawCard<*>) -> Unit,
4242
onError: (Throwable) -> Unit,
43+
onProgress: (suspend (current: Int, total: Int) -> Unit)? = null,
4344
)
4445
}

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ abstract class PN53xReaderBackend(
6262
onCardDetected: (ScannedTag) -> Unit,
6363
onCardRead: (RawCard<*>) -> Unit,
6464
onError: (Throwable) -> Unit,
65+
onProgress: (suspend (current: Int, total: Int) -> Unit)?,
6566
) {
6667
val transport =
6768
preOpenedTransport
@@ -72,7 +73,7 @@ abstract class PN53xReaderBackend(
7273
val pn533 = PN533(transport)
7374
try {
7475
initDevice(pn533)
75-
pollLoop(pn533, onCardDetected, onCardRead, onError)
76+
pollLoop(pn533, onCardDetected, onCardRead, onError, onProgress)
7677
} finally {
7778
pn533.close()
7879
}
@@ -83,6 +84,7 @@ abstract class PN53xReaderBackend(
8384
onCardDetected: (ScannedTag) -> Unit,
8485
onCardRead: (RawCard<*>) -> Unit,
8586
onError: (Throwable) -> Unit,
87+
onProgress: (suspend (current: Int, total: Int) -> Unit)?,
8688
) {
8789
while (true) {
8890
println("[$name] Polling for cards...")
@@ -118,7 +120,7 @@ abstract class PN53xReaderBackend(
118120
onCardDetected(ScannedTag(id = tagId, techList = listOf(cardTypeName)))
119121

120122
try {
121-
val rawCard = readTarget(pn533, target)
123+
val rawCard = readTarget(pn533, target, onProgress)
122124
onCardRead(rawCard)
123125
println("[$name] Card read successfully")
124126
} catch (e: Exception) {
@@ -141,15 +143,17 @@ abstract class PN53xReaderBackend(
141143
private suspend fun readTarget(
142144
pn533: PN533,
143145
target: PN533.TargetInfo,
146+
onProgress: (suspend (current: Int, total: Int) -> Unit)?,
144147
): RawCard<*> =
145148
when (target) {
146-
is PN533.TargetInfo.TypeA -> readTypeACard(pn533, target)
147-
is PN533.TargetInfo.FeliCa -> readFeliCaCard(pn533, target)
149+
is PN533.TargetInfo.TypeA -> readTypeACard(pn533, target, onProgress)
150+
is PN533.TargetInfo.FeliCa -> readFeliCaCard(pn533, target, onProgress)
148151
}
149152

150153
private suspend fun readTypeACard(
151154
pn533: PN533,
152155
target: PN533.TargetInfo.TypeA,
156+
onProgress: (suspend (current: Int, total: Int) -> Unit)?,
153157
): RawCard<*> {
154158
val info = PN533CardInfo.fromTypeA(target)
155159
val tagId = target.uid
@@ -158,39 +162,40 @@ abstract class PN53xReaderBackend(
158162
return when (info.cardType) {
159163
CardType.MifareDesfire, CardType.ISO7816 -> {
160164
val transceiver = createTransceiver(pn533, target.tg)
161-
ISO7816Dispatcher.readCard(tagId, transceiver)
165+
ISO7816Dispatcher.readCard(tagId, transceiver, onProgress)
162166
}
163167

164168
CardType.MifareClassic -> {
165169
val tech = PN533ClassicTechnology(pn533, target.tg, tagId, info)
166-
ClassicCardReader.readCard(tagId, tech, null)
170+
ClassicCardReader.readCard(tagId, tech, null, onProgress = onProgress)
167171
}
168172

169173
CardType.MifareUltralight -> {
170174
val tech = PN533UltralightTechnology(pn533, target.tg, info)
171-
UltralightCardReader.readCard(tagId, tech)
175+
UltralightCardReader.readCard(tagId, tech, onProgress)
172176
}
173177

174178
CardType.CEPAS -> {
175179
val transceiver = createTransceiver(pn533, target.tg)
176-
CEPASCardReader.readCard(tagId, transceiver)
180+
CEPASCardReader.readCard(tagId, transceiver, onProgress)
177181
}
178182

179183
else -> {
180184
val transceiver = createTransceiver(pn533, target.tg)
181-
ISO7816Dispatcher.readCard(tagId, transceiver)
185+
ISO7816Dispatcher.readCard(tagId, transceiver, onProgress)
182186
}
183187
}
184188
}
185189

186190
private suspend fun readFeliCaCard(
187191
pn533: PN533,
188192
target: PN533.TargetInfo.FeliCa,
193+
onProgress: (suspend (current: Int, total: Int) -> Unit)?,
189194
): RawCard<*> {
190195
val tagId = target.idm
191196
println("[$name] FeliCa card: IDm=${tagId.hex()}")
192197
val adapter = PN533FeliCaTagAdapter(pn533, target.idm)
193-
return FeliCaReader.readTag(tagId, adapter)
198+
return FeliCaReader.readTag(tagId, adapter, onProgress = onProgress)
194199
}
195200

196201
private suspend fun waitForRemoval(pn533: PN533) {

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class PcscReaderBackend : NfcReaderBackend {
5454
onCardDetected: (ScannedTag) -> Unit,
5555
onCardRead: (RawCard<*>) -> Unit,
5656
onError: (Throwable) -> Unit,
57+
onProgress: (suspend (current: Int, total: Int) -> Unit)?,
5758
) {
5859
val factory = TerminalFactory.getDefault()
5960
val terminals =
@@ -95,7 +96,7 @@ class PcscReaderBackend : NfcReaderBackend {
9596
println("[PC/SC] Tag ID: ${tagId.hex()}")
9697

9798
onCardDetected(ScannedTag(id = tagId, techList = listOf(info.cardType.name)))
98-
val rawCard = readCard(info, channel, tagId)
99+
val rawCard = readCard(info, channel, tagId, onProgress)
99100
onCardRead(rawCard)
100101
println("[PC/SC] Card read successfully")
101102
} finally {
@@ -118,31 +119,32 @@ class PcscReaderBackend : NfcReaderBackend {
118119
info: PCSCCardInfo,
119120
channel: javax.smartcardio.CardChannel,
120121
tagId: ByteArray,
122+
onProgress: (suspend (current: Int, total: Int) -> Unit)?,
121123
): RawCard<*> =
122124
when (info.cardType) {
123125
CardType.MifareDesfire, CardType.ISO7816 -> {
124126
val transceiver = PCSCCardTransceiver(channel)
125-
ISO7816Dispatcher.readCard(tagId, transceiver)
127+
ISO7816Dispatcher.readCard(tagId, transceiver, onProgress)
126128
}
127129

128130
CardType.MifareClassic -> {
129131
val tech = PCSCClassicTechnology(channel, info)
130-
ClassicCardReader.readCard(tagId, tech, null)
132+
ClassicCardReader.readCard(tagId, tech, null, onProgress = onProgress)
131133
}
132134

133135
CardType.MifareUltralight -> {
134136
val tech = PCSCUltralightTechnology(channel, info)
135-
UltralightCardReader.readCard(tagId, tech)
137+
UltralightCardReader.readCard(tagId, tech, onProgress)
136138
}
137139

138140
CardType.FeliCa -> {
139141
val adapter = PCSCFeliCaTagAdapter(channel)
140-
FeliCaReader.readTag(tagId, adapter)
142+
FeliCaReader.readTag(tagId, adapter, onProgress = onProgress)
141143
}
142144

143145
CardType.CEPAS -> {
144146
val transceiver = PCSCCardTransceiver(channel)
145-
CEPASCardReader.readCard(tagId, transceiver)
147+
CEPASCardReader.readCard(tagId, transceiver, onProgress)
146148
}
147149

148150
CardType.Vicinity -> {
@@ -152,7 +154,7 @@ class PcscReaderBackend : NfcReaderBackend {
152154

153155
else -> {
154156
val transceiver = PCSCCardTransceiver(channel)
155-
ISO7816Dispatcher.readCard(tagId, transceiver)
157+
ISO7816Dispatcher.readCard(tagId, transceiver, onProgress)
156158
}
157159
}
158160

app/src/androidMain/kotlin/com/codebutler/farebot/app/core/nfc/ISO7816TagReader.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,6 @@ class ISO7816TagReader(
4848
tag: Tag,
4949
tech: CardTransceiver,
5050
cardKeys: CardKeys?,
51-
): RawCard<*> = ISO7816Dispatcher.readCard(tagId, tech)
51+
onProgress: (suspend (current: Int, total: Int) -> Unit)?,
52+
): RawCard<*> = ISO7816Dispatcher.readCard(tagId, tech, onProgress)
5253
}

app/src/androidMain/kotlin/com/codebutler/farebot/app/feature/home/AndroidCardScanner.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.codebutler.farebot.key.CardKeys
1010
import com.codebutler.farebot.persist.CardKeysPersister
1111
import com.codebutler.farebot.shared.nfc.CardScanner
1212
import com.codebutler.farebot.shared.nfc.CardUnauthorizedException
13+
import com.codebutler.farebot.shared.nfc.ReadingProgress
1314
import com.codebutler.farebot.shared.nfc.ScannedTag
1415
import kotlinx.coroutines.CoroutineScope
1516
import kotlinx.coroutines.Dispatchers
@@ -51,6 +52,9 @@ class AndroidCardScanner(
5152
private val _isScanning = MutableStateFlow(false)
5253
override val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
5354

55+
private val _readingProgress = MutableStateFlow<ReadingProgress?>(null)
56+
override val readingProgress: StateFlow<ReadingProgress?> = _readingProgress.asStateFlow()
57+
5458
private var isObserving = false
5559

5660
fun startObservingTags() {
@@ -65,12 +69,17 @@ class AndroidCardScanner(
6569
_isScanning.value = true
6670
try {
6771
val cardKeys = getCardKeys(ByteUtils.getHexString(tag.id))
68-
val rawCard = tagReaderFactory.getTagReader(tag.id, tag, cardKeys).readTag()
72+
val rawCard =
73+
tagReaderFactory.getTagReader(tag.id, tag, cardKeys).readTag { current, total ->
74+
_readingProgress.value = ReadingProgress(current, total)
75+
}
76+
_readingProgress.value = null
6977
if (rawCard.isUnauthorized()) {
7078
throw CardUnauthorizedException(rawCard.tagId(), rawCard.cardType())
7179
}
7280
_scannedCards.emit(rawCard)
7381
} catch (error: Throwable) {
82+
_readingProgress.value = null
7483
_scanErrors.emit(error)
7584
} finally {
7685
_isScanning.value = false

app/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
<string name="tab_explore">Explore</string>
8181
<string name="scan">Scan</string>
8282
<string name="reading_card">Reading\u2026</string>
83+
<string name="hold_card_near_reader">Hold your card near the reader</string>
8384
<string name="search_supported_cards">Search</string>
8485
<string name="nfc_disabled_tap_to_enable">Enable NFC to scan cards</string>
8586

app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ fun FareBotApp(
152152
navController.navigate(Screen.AddKey.createRoute(tagId, cardType))
153153
},
154154
onScanCard = { homeViewModel.startActiveScan() },
155+
onCancelScan = { homeViewModel.stopActiveScan() },
155156
historyUiState = historyUiState,
156157
onNavigateToCard = { itemId ->
157158
val cardKey = historyViewModel.getCardNavKey(itemId)

app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/CardScanner.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ package com.codebutler.farebot.shared.nfc
2424

2525
import com.codebutler.farebot.card.RawCard
2626
import kotlinx.coroutines.flow.MutableSharedFlow
27+
import kotlinx.coroutines.flow.MutableStateFlow
2728
import kotlinx.coroutines.flow.SharedFlow
2829
import kotlinx.coroutines.flow.StateFlow
2930

31+
data class ReadingProgress(
32+
val current: Int,
33+
val total: Int,
34+
)
35+
3036
data class ScannedTag(
3137
val id: ByteArray,
3238
val techList: List<String>,
@@ -66,6 +72,10 @@ interface CardScanner {
6672
/** Whether scanning is currently in progress. */
6773
val isScanning: StateFlow<Boolean>
6874

75+
/** Reading progress — non-null when a card read is in progress with known total. */
76+
val readingProgress: StateFlow<ReadingProgress?>
77+
get() = MutableStateFlow(null)
78+
6979
/**
7080
* Start an active scan session (e.g., iOS NFC dialog).
7181
* Results are emitted to [scannedCards].

app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/ISO7816Dispatcher.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,25 @@ object ISO7816Dispatcher {
3939
suspend fun readCard(
4040
tagId: ByteArray,
4141
transceiver: CardTransceiver,
42+
onProgress: (suspend (current: Int, total: Int) -> Unit)? = null,
4243
): RawCard<*> {
43-
val iso7816Card = tryISO7816(tagId, transceiver)
44+
val iso7816Card = tryISO7816(tagId, transceiver, onProgress)
4445
if (iso7816Card != null) {
4546
return iso7816Card
4647
}
47-
return DesfireCardReader.readCard(tagId, transceiver)
48+
return DesfireCardReader.readCard(tagId, transceiver, onProgress)
4849
}
4950

5051
private suspend fun tryISO7816(
5152
tagId: ByteArray,
5253
transceiver: CardTransceiver,
54+
onProgress: (suspend (current: Int, total: Int) -> Unit)? = null,
5355
): RawCard<*>? {
5456
val appConfigs = buildAppConfigs()
5557
if (appConfigs.isEmpty()) return null
5658

5759
return try {
58-
ISO7816CardReader.readCard(tagId, transceiver, appConfigs)
60+
ISO7816CardReader.readCard(tagId, transceiver, appConfigs, onProgress)
5961
} catch (e: Exception) {
6062
println("[ISO7816Dispatcher] ISO7816 read attempt failed: $e")
6163
null

0 commit comments

Comments
 (0)