diff --git a/README.md b/README.md index 828cd3796..5a2d38612 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Some MIFARE Classic cards require encryption keys to read. You can obtain keys u * **Android:** NFC-enabled device running Android 6.0 (API 23) or later * **iOS:** iPhone 7 or later with iOS support for CoreNFC * **macOS** (experimental): Mac with a PC/SC-compatible NFC smart card reader (e.g., ACR122U), a PN533-based USB NFC controller (e.g., SCL3711), or a Sony RC-S956 (PaSoRi) USB NFC reader -* **Web** (experimental): Any modern browser with WebAssembly support. Card data can be imported from JSON files exported by other platforms. NFC reading via WebUSB is planned but not yet implemented. +* **Web** (experimental): Any modern browser with WebAssembly support. Card data can be imported from JSON files exported by other platforms. Live NFC card reading is supported in Chrome/Edge/Opera via WebUSB with a PN533-based USB NFC reader (e.g., SCL3711). ## Building diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopPlatformActions.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopPlatformActions.kt index 2494c2600..88d4fb559 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopPlatformActions.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopPlatformActions.kt @@ -3,7 +3,6 @@ package com.codebutler.farebot.desktop import com.codebutler.farebot.shared.platform.PlatformActions import java.awt.Desktop import java.awt.Toolkit -import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection import java.io.File import java.net.URI @@ -27,15 +26,6 @@ class DesktopPlatformActions : PlatformActions { clipboard.setContents(StringSelection(text), null) } - override fun getClipboardText(): String? { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - return try { - clipboard.getData(DataFlavor.stringFlavor) as? String - } catch (_: Exception) { - null - } - } - override fun shareText(text: String) { copyToClipboard(text) showToast("Copied to clipboard") diff --git a/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/platform/AndroidPlatformActions.kt b/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/platform/AndroidPlatformActions.kt index d2d7e069e..232881bd8 100644 --- a/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/platform/AndroidPlatformActions.kt +++ b/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/platform/AndroidPlatformActions.kt @@ -84,14 +84,6 @@ class AndroidPlatformActions( clipboard.setPrimaryClip(ClipData.newPlainText("FareBot", text)) } - override fun getClipboardText(): String? { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - return clipboard.primaryClip - ?.getItemAt(0) - ?.text - ?.toString() - } - override fun shareText(text: String) { val intent = Intent(Intent.ACTION_SEND).apply { diff --git a/app/src/commonMain/composeResources/files/samples/EZLink.json b/app/src/commonMain/composeResources/files/samples/EZLink.json deleted file mode 100644 index 76d0d17e9..000000000 --- a/app/src/commonMain/composeResources/files/samples/EZLink.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "tagId": "a1b2c3d4", - "scannedAt": { - "timeInMillis": 1, - "tz": "LOCAL" - }, - "cepasCompat": { - "purses": [ - { - "id": 15 - }, - { - "id": 14 - }, - { - "id": 13 - }, - { - "id": 12 - }, - { - "id": 11 - }, - { - "id": 10 - }, - { - "id": 9 - }, - { - "id": 8 - }, - { - "id": 7 - }, - { - "id": 6 - }, - { - "id": 5 - }, - { - "id": 4 - }, - { - "can": "1123456789123456", - "id": 3, - "purseBalance": 897 - }, - { - "id": 2 - }, - { - "id": 1 - }, - { - } - ], - "histories": [ - { - "id": 15, - "transactions": [ - ] - }, - { - "id": 14, - "transactions": [ - ] - }, - { - "id": 13, - "transactions": [ - ] - }, - { - "id": 12, - "transactions": [ - ] - }, - { - "id": 11, - "transactions": [ - ] - }, - { - "id": 10, - "transactions": [ - ] - }, - { - "id": 9, - "transactions": [ - ] - }, - { - "id": 8, - "transactions": [ - ] - }, - { - "id": 7, - "transactions": [ - ] - }, - { - "id": 6, - "transactions": [ - ] - }, - { - "id": 5, - "transactions": [ - ] - }, - { - "id": 4, - "transactions": [ - ] - }, - { - "id": 3, - "transactions": [ - { - "type": -16, - "amount": 65536, - "date": 0, - "date2": 1262188800000, - "user-data": "" - }, - { - "type": 49, - "amount": -30, - "date": 0, - "date2": 1262361600000, - "user-data": "BUS106 " - }, - { - "type": 118, - "amount": 30, - "date": 0, - "date2": 1262361600000, - "user-data": "BUS106 " - }, - { - "type": 48, - "amount": -169, - "date": 0, - "date2": 1262275200000, - "user-data": "BFT-CGA " - } - ] - }, - { - "id": 2, - "transactions": [ - ] - }, - { - "id": 1, - "transactions": [ - ] - }, - { - "transactions": [ - ] - } - ], - "isPartialRead": false - } -} \ No newline at end of file diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/platform/PlatformActions.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/platform/PlatformActions.kt index 12eda6f89..738921868 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/platform/PlatformActions.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/platform/PlatformActions.kt @@ -11,8 +11,6 @@ interface PlatformActions { fun copyToClipboard(text: String) - fun getClipboardText(): String? - fun shareText(text: String) fun showToast(message: String) diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/MetrodroidJsonParser.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/MetrodroidJsonParser.kt index 15eef3523..488cd2861 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/MetrodroidJsonParser.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/MetrodroidJsonParser.kt @@ -23,13 +23,7 @@ package com.codebutler.farebot.shared.serialize import com.codebutler.farebot.base.util.ByteUtils -import com.codebutler.farebot.card.Card -import com.codebutler.farebot.card.CardType import com.codebutler.farebot.card.RawCard -import com.codebutler.farebot.card.cepas.CEPASCard -import com.codebutler.farebot.card.cepas.CEPASHistory -import com.codebutler.farebot.card.cepas.CEPASPurse -import com.codebutler.farebot.card.cepas.CEPASTransaction import com.codebutler.farebot.card.classic.raw.RawClassicBlock import com.codebutler.farebot.card.classic.raw.RawClassicCard import com.codebutler.farebot.card.classic.raw.RawClassicSector @@ -79,8 +73,6 @@ object MetrodroidJsonParser { parseClassic(obj["mifareClassic"]!!.jsonObject, tagId, scannedAt) obj.containsKey("iso7816") -> parseISO7816(obj["iso7816"]!!.jsonObject, tagId, scannedAt) - obj.containsKey("cepasCompat") -> - parseCEPAS(obj["cepasCompat"]!!.jsonObject, tagId, scannedAt) obj.containsKey("felica") -> parseFelica(obj["felica"]!!.jsonObject, tagId, scannedAt) else -> null @@ -399,148 +391,6 @@ object MetrodroidJsonParser { return FelicaService.create(serviceCode, blocks) } - // --- CEPAS (compat format) --- - - private fun parseCEPAS( - cepas: JsonObject, - tagId: ByteArray, - scannedAt: Instant, - ): RawCard { - val pursesArray = cepas["purses"]?.jsonArray ?: JsonArray(emptyList()) - val historiesArray = cepas["histories"]?.jsonArray ?: JsonArray(emptyList()) - - val purses = parseCEPASPurses(pursesArray) - val histories = parseCEPASHistories(historiesArray) - val card = CEPASCard.create(tagId, scannedAt, purses, histories) - - return PreParsedRawCard(CardType.CEPAS, tagId, scannedAt, card) - } - - private fun parseCEPASPurses(pursesArray: JsonArray): List { - val parsedById = mutableMapOf() - - pursesArray.forEachIndexed { index, purseElement -> - val purseObj = purseElement.jsonObject - val id = purseObj["id"]?.jsonPrimitive?.intOrNull ?: index - val purseBalance = purseObj["purseBalance"]?.jsonPrimitive?.intOrNull - val canStr = purseObj["can"]?.jsonPrimitive?.content - - val purse = - if (purseBalance != null) { - // Purse with data — CAN is a hex string representing raw bytes - val can = if (canStr != null) hexToBytes(canStr) else ByteArray(8) - - CEPASPurse( - id = id, - cepasVersion = 0, - purseStatus = 0, - purseBalance = purseBalance, - autoLoadAmount = 0, - can = can, - csn = ByteArray(8), - purseExpiryDate = 0, - purseCreationDate = 0, - lastCreditTransactionTRP = 0, - lastCreditTransactionHeader = ByteArray(8), - logfileRecordCount = 0, - issuerDataLength = 0, - lastTransactionTRP = 0, - lastTransactionRecord = null, - issuerSpecificData = ByteArray(0), - lastTransactionDebitOptionsByte = 0, - isValid = true, - errorMessage = null, - ) - } else { - // Empty purse - CEPASPurse( - id = id, - cepasVersion = 0, - purseStatus = 0, - purseBalance = 0, - autoLoadAmount = 0, - can = null, - csn = null, - purseExpiryDate = 0, - purseCreationDate = 0, - lastCreditTransactionTRP = 0, - lastCreditTransactionHeader = null, - logfileRecordCount = 0, - issuerDataLength = 0, - lastTransactionTRP = 0, - lastTransactionRecord = null, - issuerSpecificData = null, - lastTransactionDebitOptionsByte = 0, - isValid = false, - errorMessage = "No purse data", - ) - } - parsedById[id] = purse - } - - // Return purses ordered by ID (0..15) so getPurse(n) returns purse with ID n - return (0..15).map { id -> - parsedById[id] ?: CEPASPurse( - id = id, - cepasVersion = 0, - purseStatus = 0, - purseBalance = 0, - autoLoadAmount = 0, - can = null, - csn = null, - purseExpiryDate = 0, - purseCreationDate = 0, - lastCreditTransactionTRP = 0, - lastCreditTransactionHeader = null, - logfileRecordCount = 0, - issuerDataLength = 0, - lastTransactionTRP = 0, - lastTransactionRecord = null, - issuerSpecificData = null, - lastTransactionDebitOptionsByte = 0, - isValid = false, - errorMessage = "No purse data", - ) - } - } - - private fun parseCEPASHistories(historiesArray: JsonArray): List { - val parsedById = mutableMapOf() - - historiesArray.forEachIndexed { index, histElement -> - val histObj = histElement.jsonObject - val id = histObj["id"]?.jsonPrimitive?.intOrNull ?: index - val transactionsArray = histObj["transactions"]?.jsonArray - - val history = - if (transactionsArray != null && transactionsArray.isNotEmpty()) { - val transactions = - transactionsArray.map { txElement -> - parseCEPASTransaction(txElement.jsonObject) - } - CEPASHistory.create(id, transactions) - } else { - CEPASHistory.create(id, emptyList()) - } - parsedById[id] = history - } - - // Return histories ordered by ID (0..15) so getHistory(n) returns history with ID n - return (0..15).map { id -> - parsedById[id] ?: CEPASHistory.create(id, emptyList()) - } - } - - private fun parseCEPASTransaction(txObj: JsonObject): CEPASTransaction { - val type = txObj["type"]?.jsonPrimitive?.intOrNull ?: 0 - val amount = txObj["amount"]?.jsonPrimitive?.intOrNull ?: 0 - // date2 is milliseconds since Unix epoch - val date2 = txObj["date2"]?.jsonPrimitive?.longOrNull ?: 0L - val timestamp = (date2 / 1000).toInt() - val userData = txObj["user-data"]?.jsonPrimitive?.content ?: "" - return CEPASTransaction(type, amount, timestamp, userData) - } - // --- Helpers --- private fun hexToBytes(hex: String): ByteArray { @@ -551,26 +401,4 @@ object MetrodroidJsonParser { ByteArray(0) } } - - /** - * A RawCard wrapper that holds a pre-parsed Card object. - * Used for card types (like CEPAS compat) where the Metrodroid format - * provides decoded fields rather than raw binary data. - */ - private class PreParsedRawCard( - private val _cardType: CardType, - private val _tagId: ByteArray, - private val _scannedAt: Instant, - private val parsed: T, - ) : RawCard { - override fun cardType() = _cardType - - override fun tagId() = _tagId - - override fun scannedAt() = _scannedAt - - override fun isUnauthorized() = false - - override fun parse() = parsed - } } diff --git a/app/src/commonTest/kotlin/com/codebutler/farebot/test/SampleDumpIntegrationTest.kt b/app/src/commonTest/kotlin/com/codebutler/farebot/test/SampleDumpIntegrationTest.kt index 824f6388e..f4f50ee8b 100644 --- a/app/src/commonTest/kotlin/com/codebutler/farebot/test/SampleDumpIntegrationTest.kt +++ b/app/src/commonTest/kotlin/com/codebutler/farebot/test/SampleDumpIntegrationTest.kt @@ -22,7 +22,6 @@ package com.codebutler.farebot.test -import com.codebutler.farebot.card.cepas.CEPASCard import com.codebutler.farebot.card.classic.ClassicCard import com.codebutler.farebot.card.desfire.DesfireCard import com.codebutler.farebot.card.felica.FelicaCard @@ -37,8 +36,6 @@ import com.codebutler.farebot.transit.bilheteunico.BilheteUnicoSPTransitFactory import com.codebutler.farebot.transit.bilheteunico.BilheteUnicoSPTransitInfo import com.codebutler.farebot.transit.calypso.mobib.MobibTransitInfo import com.codebutler.farebot.transit.easycard.EasyCardTransitFactory -import com.codebutler.farebot.transit.ezlink.EZLinkTransitFactory -import com.codebutler.farebot.transit.ezlink.EZLinkTransitInfo import com.codebutler.farebot.transit.hsl.HSLTransitFactory import com.codebutler.farebot.transit.hsl.HSLTransitInfo import com.codebutler.farebot.transit.hsl.HSLUltralightTransitFactory @@ -246,58 +243,6 @@ class SampleDumpIntegrationTest : CardDumpTest() { assertTrue(trips.isNotEmpty(), "Should have trips") } - // --- EZ-Link/NETS (CEPAS) --- - // Source: Metrodroid test asset legacy.json - // Card: EZ-Link/NETS, Singapore - // Balance: $8.97 SGD (897 cents), 4 trips, serial: 1123456789123456 - - @Test - fun testEZLinkDump() = - runTest { - val factory = EZLinkTransitFactory() - val (card, info) = - loadAndParseMetrodroidJson( - "cepas/EZLink.json", - factory, - ) - - val identity = factory.parseIdentity(card) - // CAN "112..." maps to generic CEPAS issuer (not specifically EZ-Link "100...") - assertNotNull(identity.name) - assertNotNull(identity.serialNumber) - - // Balance: $8.97 SGD (897 cents) - val balances = info.balances - assertNotNull(balances) - assertEquals(1, balances.size) - assertEquals(TransitCurrency.SGD(897), balances[0].balance) - - // 4 trips: BUS, BUS_REFUND, MRT, CREATION - val trips = info.trips - assertNotNull(trips) - assertEquals(4, trips.size) - - // Verify expected modes are present - val modes = trips.map { it.mode } - assertEquals(2, modes.count { it == Trip.Mode.BUS }, "Should have 2 BUS trips (bus + refund)") - assertEquals(1, modes.count { it == Trip.Mode.METRO }, "Should have 1 MRT trip") - assertEquals(1, modes.count { it == Trip.Mode.OTHER }, "Should have 1 OTHER trip (creation)") - - // MRT trip should have stations - val mrtTrip = trips.first { it.mode == Trip.Mode.METRO } - assertNotNull(mrtTrip.startStation, "MRT trip should have a start station") - assertNotNull(mrtTrip.endStation, "MRT trip should have an end station") - - // Bus trips should not have stations (BUS_REFUND userData handled via toStationOrNull) - trips.filter { it.mode == Trip.Mode.BUS }.forEach { busTrip -> - assertNull(busTrip.startStation, "Bus trip should not have a station") - } - - // CREATION trip should not have a station (blank userData nullified by toStationOrNull) - val creationTrip = trips.first { it.mode == Trip.Mode.OTHER } - assertNull(creationTrip.startStation, "CREATION trip should not have a station for blank userData") - } - // --- Holo (DESFire, serial-only) --- // Source: Metrodroid test asset unused.json // Card: HOLO, Honolulu, Hawaii diff --git a/app/src/iosMain/kotlin/com/codebutler/farebot/shared/platform/IosPlatformActions.kt b/app/src/iosMain/kotlin/com/codebutler/farebot/shared/platform/IosPlatformActions.kt index d03c5a45b..9e706810c 100644 --- a/app/src/iosMain/kotlin/com/codebutler/farebot/shared/platform/IosPlatformActions.kt +++ b/app/src/iosMain/kotlin/com/codebutler/farebot/shared/platform/IosPlatformActions.kt @@ -60,8 +60,6 @@ class IosPlatformActions : PlatformActions { UIPasteboard.generalPasteboard.string = text } - override fun getClipboardText(): String? = UIPasteboard.generalPasteboard.string - override fun shareText(text: String) { val viewController = getTopViewController() ?: run { diff --git a/app/web/build.gradle.kts b/app/web/build.gradle.kts index c56e9ed66..c3df64f0f 100644 --- a/app/web/build.gradle.kts +++ b/app/web/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.compose) alias(libs.plugins.metro) diff --git a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebPlatformActions.kt b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebPlatformActions.kt index 6ff8bf2f3..fb62acfe8 100644 --- a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebPlatformActions.kt +++ b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebPlatformActions.kt @@ -125,11 +125,6 @@ class WebPlatformActions : PlatformActions { jsCopyToClipboard(text.toJsString()) } - override fun getClipboardText(): String? { - // Clipboard read requires async permission; return null for now - return null - } - override fun shareText(text: String) { copyToClipboard(text) showToast("Copied to clipboard") diff --git a/base/src/wasmJsMain/kotlin/com/codebutler/farebot/base/mdst/ResourceAccessor.kt b/base/src/wasmJsMain/kotlin/com/codebutler/farebot/base/mdst/ResourceAccessor.kt index 2347d3f66..e4cc0de37 100644 --- a/base/src/wasmJsMain/kotlin/com/codebutler/farebot/base/mdst/ResourceAccessor.kt +++ b/base/src/wasmJsMain/kotlin/com/codebutler/farebot/base/mdst/ResourceAccessor.kt @@ -10,6 +10,11 @@ import kotlin.js.ExperimentalWasmJsInterop * Load a compose resource file synchronously via XMLHttpRequest. * Returns base64-encoded content, or null on failure. * + * Uses overrideMimeType('text/plain; charset=x-user-defined') because browsers + * throw InvalidAccessError when setting responseType on synchronous XHR. + * The x-user-defined charset maps each byte to a Unicode code point, which + * we convert back to bytes via charCodeAt(i) & 0xFF. + * * Compose Resources for wasmJs serves files at: * composeResources/{package}/files/{filename} */ @@ -20,15 +25,18 @@ private fun jsLoadResourceBase64(url: JsString): JsString? = try { var xhr = new XMLHttpRequest(); xhr.open('GET', url, false); - xhr.responseType = 'arraybuffer'; + xhr.overrideMimeType('text/plain; charset=x-user-defined'); xhr.send(); if (xhr.status !== 200 && xhr.status !== 0) return null; - var bytes = new Uint8Array(xhr.response); - if (bytes.length === 0) return null; + var text = xhr.responseText; + if (text.length === 0) return null; var binary = ''; - bytes.forEach(function(b) { binary += String.fromCharCode(b); }); + for (var i = 0; i < text.length; i++) { + binary += String.fromCharCode(text.charCodeAt(i) & 0xFF); + } return btoa(binary); } catch(e) { + console.error('Failed to load resource: ' + url, e); return null; } })() diff --git a/docs/plans/2026-02-16-remove-builders-design.md b/docs/plans/2026-02-16-remove-builders-design.md deleted file mode 100644 index 9efbbd213..000000000 --- a/docs/plans/2026-02-16-remove-builders-design.md +++ /dev/null @@ -1,37 +0,0 @@ -# Remove Builder Classes - -## Problem - -The codebase has 4 Builder classes that are not idiomatic Kotlin. Kotlin data classes with named parameters and default values make Builders unnecessary. - -## Builders to Remove - -### 1. ClipperTrip.Builder -- 10 Long fields, all default to 0 -- Replace with: data class constructor with named params, all defaulting to 0 - -### 2. SeqGoTrip.Builder -- 8 fields (ints, nullable Instants, nullable Stations, Mode enum) -- Replace with: data class constructor with named params and defaults - -### 3. Station.Builder -- 8 fields (nullable Strings, one List) -- Replace with: data class constructor with named params, all defaulting to null/emptyList() - -### 4. FareBotUiTree.Builder + Item.Builder -- Hierarchical builder for UI tree structures -- Item.title is currently `String`, resolved from `FormattedString` during `suspend build()` -- `@Serializable` annotation is unnecessary (never serialized) -- Replace with: - - Drop `@Serializable` from FareBotUiTree and Item - - Change `Item.title` from `String` to `FormattedString` - - Remove both Builder classes - - Rewrite `uiTree {}` DSL to build Item objects directly - - Update all ~20 call sites that use the builder directly - -## Approach - -- Change `Item.title` to `FormattedString`, resolve at the UI layer instead -- Rewrite `UiTreeBuilder.kt` DSL to construct items directly (no intermediate Builder) -- For ClipperTrip, SeqGoTrip, Station: add default parameter values, remove Builder + companion factory -- Update all call sites diff --git a/docs/plans/2026-02-16-remove-builders.md b/docs/plans/2026-02-16-remove-builders.md deleted file mode 100644 index 97f14c60b..000000000 --- a/docs/plans/2026-02-16-remove-builders.md +++ /dev/null @@ -1,864 +0,0 @@ -# Remove Builder Classes Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Remove all Builder classes and replace with idiomatic Kotlin patterns (data class constructors, DSL). - -**Architecture:** Three simple Builder classes (ClipperTrip, SeqGoTrip, Station) are replaced with data class constructors using named parameters and defaults. The hierarchical FareBotUiTree Builder is replaced by rewriting the existing `uiTree {}` DSL to build items directly, and converting all 19 imperative builder call sites to use the DSL. - -**Tech Stack:** Kotlin Multiplatform, Compose Multiplatform, compose-resources - ---- - -## Task 1: Rewrite FareBotUiTree data model - -**Files:** -- Modify: `base/src/commonMain/kotlin/com/codebutler/farebot/base/ui/FareBotUiTree.kt` - -**Step 1: Rewrite FareBotUiTree.kt** - -Replace the entire file with: - -```kotlin -package com.codebutler.farebot.base.ui - -import com.codebutler.farebot.base.util.FormattedString - -data class FareBotUiTree( - val items: List, -) { - data class Item( - val title: FormattedString, - val value: Any? = null, - val children: List = emptyList(), - ) -} -``` - -Key changes: -- Remove `@Serializable` from both classes (never serialized) -- Change `Item.title` from `String` to `FormattedString` -- Remove `Item.Builder`, `FareBotUiTree.Builder`, companion objects -- Remove `@Contextual` annotation on `value` -- `children` and `value` get defaults - -**Step 2: Commit** - -```bash -git add base/src/commonMain/kotlin/com/codebutler/farebot/base/ui/FareBotUiTree.kt -git commit -m "refactor: simplify FareBotUiTree to plain data classes - -Remove Builder classes and @Serializable annotation. Change Item.title -from String to FormattedString to defer resolution to UI layer." -``` - ---- - -## Task 2: Rewrite UiTreeBuilder DSL - -**Files:** -- Modify: `base/src/commonMain/kotlin/com/codebutler/farebot/base/ui/UiTreeBuilder.kt` - -**Step 1: Rewrite UiTreeBuilder.kt** - -Replace the entire file with: - -```kotlin -package com.codebutler.farebot.base.ui - -import com.codebutler.farebot.base.util.FormattedString -import org.jetbrains.compose.resources.StringResource - -@DslMarker -private annotation class UiTreeBuilderMarker - -fun uiTree(init: TreeScope.() -> Unit): FareBotUiTree { - val scope = TreeScope() - scope.init() - return FareBotUiTree(scope.items.toList()) -} - -@UiTreeBuilderMarker -class TreeScope { - internal val items = mutableListOf() - - fun item(init: ItemScope.() -> Unit) { - val scope = ItemScope() - scope.init() - items.add(scope.build()) - } -} - -@UiTreeBuilderMarker -class ItemScope { - private var _title: FormattedString = FormattedString("") - var title: Any? - get() = _title - set(value) { - _title = when (value) { - is FormattedString -> value - is StringResource -> FormattedString(value) - else -> FormattedString(value.toString()) - } - } - - var value: Any? = null - - private val children = mutableListOf() - - fun item(init: ItemScope.() -> Unit) { - val scope = ItemScope() - scope.init() - children.add(scope.build()) - } - - fun addChildren(items: List) { - children.addAll(items) - } - - internal fun build(): FareBotUiTree.Item = FareBotUiTree.Item( - title = _title, - value = value, - children = children.toList(), - ) -} -``` - -Key changes: -- `uiTree` is no longer `suspend` (no async string resolution during build) -- DSL builds `Item` objects directly instead of delegating to Builder -- `ItemScope.title` accepts `Any?` (String, StringResource, FormattedString) for ergonomics -- `addChildren()` allows appending pre-built items (for OVChipIndex pattern) - -**Step 2: Commit** - -```bash -git add base/src/commonMain/kotlin/com/codebutler/farebot/base/ui/UiTreeBuilder.kt -git commit -m "refactor: rewrite uiTree DSL to build items directly - -No longer wraps Builder classes. The DSL constructs FareBotUiTree.Item -objects directly. uiTree is no longer suspend since FormattedString -resolution is deferred to the UI layer." -``` - ---- - -## Task 3: Update CardAdvancedScreen for FormattedString title - -**Files:** -- Modify: `app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/CardAdvancedScreen.kt` - -**Step 1: Change `item.title.orEmpty()` to `item.title.resolve()`** - -In the `TreeItemView` composable, change: -```kotlin -Text( - text = item.title.orEmpty(), -``` -to: -```kotlin -Text( - text = item.title.resolve(), -``` - -The `resolve()` method is a `@Composable` function on `FormattedString` that resolves string resources at render time. - -**Step 2: Commit** - -```bash -git add app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/CardAdvancedScreen.kt -git commit -m "refactor: resolve FormattedString title in CardAdvancedScreen" -``` - ---- - -## Task 4: Convert card module getAdvancedUi() to DSL - -**Files (8):** -- `card/classic/src/commonMain/kotlin/.../ClassicCard.kt` -- `card/desfire/src/commonMain/kotlin/.../DesfireCard.kt` -- `card/felica/src/commonMain/kotlin/.../FelicaCard.kt` -- `card/iso7816/src/commonMain/kotlin/.../ISO7816Card.kt` -- `card/cepas/src/commonMain/kotlin/.../CEPASCard.kt` -- `card/ultralight/src/commonMain/kotlin/.../UltralightCard.kt` -- `card/vicinity/src/commonMain/kotlin/.../VicinityCard.kt` -- `card/ksx6924/src/commonMain/kotlin/.../KSX6924PurseInfo.kt` - -**Step 1: Convert each file's getAdvancedUi() from builder to DSL** - -Each file follows the same pattern. Replace: -```kotlin -import com.codebutler.farebot.base.ui.FareBotUiTree -// ... -val builder = FareBotUiTree.builder() -builder.item().title("X").value(y) -return builder.build() -``` -with: -```kotlin -import com.codebutler.farebot.base.ui.uiTree -// ... -return uiTree { - item { title = "X"; value = y } -} -``` - -Specific conversions per file: - -**ClassicCard.kt** — nested sectors with conditional titles: -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - for (sector in sectors) { - val sectorIndexString = sector.index.toString(16) - item { - when (sector) { - is UnauthorizedClassicSector -> { - title = FormattedString(Res.string.classic_unauthorized_sector_title_format, sectorIndexString) - } - is InvalidClassicSector -> { - title = FormattedString(Res.string.classic_invalid_sector_title_format, sectorIndexString, sector.error) - } - else -> { - val dataClassicSector = sector as DataClassicSector - title = FormattedString(Res.string.classic_sector_title_format, sectorIndexString) - for (block in dataClassicSector.blocks) { - item { - title = FormattedString(Res.string.classic_block_title_format, block.index.toString()) - value = block.data - } - } - } - } - } - } -} -``` - -**DesfireCard.kt** — deeply nested apps/files/settings: -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - item { - title = "Applications" - for (app in applications) { - item { - title = "Application: 0x${app.id.toString(16)}" - item { - title = "Files" - for (file in app.files) { - item { - title = "File: 0x${file.id.toString(16)}" - val fileSettings = file.fileSettings - if (fileSettings != null) { - item { - title = "Settings" - item { title = "Type"; value = fileSettings.fileTypeName } - if (fileSettings is StandardDesfireFileSettings) { - item { title = "Size"; value = fileSettings.fileSize } - } else if (fileSettings is RecordDesfireFileSettings) { - item { title = "Cur Records"; value = fileSettings.curRecords } - item { title = "Max Records"; value = fileSettings.maxRecords } - item { title = "Record Size"; value = fileSettings.recordSize } - } else if (fileSettings is ValueDesfireFileSettings) { - item { title = "Range"; value = "${fileSettings.lowerLimit} - ${fileSettings.upperLimit}" } - item { - title = "Limited Credit" - value = "${fileSettings.limitedCreditValue} (${if (fileSettings.limitedCreditEnabled) "enabled" else "disabled"})" - } - } - } - } - if (file is StandardDesfireFile) { - item { title = "Data"; value = file.data } - } else if (file is RecordDesfireFile) { - item { - title = "Records" - for (i in file.records.indices) { - item { title = "Record $i"; value = file.records[i].data } - } - } - } else if (file is ValueDesfireFile) { - item { title = "Value"; value = file.value } - } else if (file is InvalidDesfireFile) { - item { title = "Error"; value = file.errorMessage } - } else if (file is UnauthorizedDesfireFile) { - item { title = "Error"; value = file.errorMessage } - } - } - } - } - } - } - } - item { - title = "Manufacturing Data" - item { - title = "Hardware Information" - item { title = "Vendor ID"; value = manufacturingData.hwVendorID } - item { title = "Type"; value = manufacturingData.hwType } - item { title = "Subtype"; value = manufacturingData.hwSubType } - item { title = "Major Version"; value = manufacturingData.hwMajorVersion } - item { title = "Minor Version"; value = manufacturingData.hwMinorVersion } - item { title = "Storage Size"; value = manufacturingData.hwStorageSize } - item { title = "Protocol"; value = manufacturingData.hwProtocol } - } - item { - title = "Software Information" - item { title = "Vendor ID"; value = manufacturingData.swVendorID } - item { title = "Type"; value = manufacturingData.swType } - item { title = "Subtype"; value = manufacturingData.swSubType } - item { title = "Major Version"; value = manufacturingData.swMajorVersion } - item { title = "Minor Version"; value = manufacturingData.swMinorVersion } - item { title = "Storage Size"; value = manufacturingData.swStorageSize } - item { title = "Protocol"; value = manufacturingData.swProtocol } - } - item { - title = "General Information" - item { title = "Serial Number"; value = manufacturingData.uidHex } - item { title = "Batch Number"; value = manufacturingData.batchNoHex } - item { title = "Week of Production"; value = manufacturingData.weekProd.toString(16) } - item { title = "Year of Production"; value = manufacturingData.yearProd.toString(16) } - } - } -} -``` - -**FelicaCard.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - item { title = "IDm"; value = idm } - item { title = "PMm"; value = pmm } - item { - title = "Systems" - for (system in systems) { - item { - title = "System: ${system.code.toString(16)}" - for (service in system.services) { - item { - title = "Service: 0x${service.serviceCode.toString(16)} (${FelicaUtils.getFriendlyServiceName(system.code, service.serviceCode)})" - for (block in service.blocks) { - item { - title = "Block ${block.address.toString().padStart(2, '0')}" - value = block.data - } - } - } - } - } - } - } -} -``` - -**ISO7816Card.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - item { - title = "Applications" - for (app in applications) { - item { - val appNameStr = app.appName?.let { formatAID(it) } ?: "Unknown" - title = "Application: $appNameStr (${app.type})" - if (app.files.isNotEmpty()) { - item { - title = "Files" - for ((selector, file) in app.files) { - item { - title = "File: $selector" - if (file.binaryData != null) { - item { title = "Binary Data"; value = file.binaryData } - } - for ((index, record) in file.records.entries.sortedBy { it.key }) { - item { title = "Record $index"; value = record } - } - } - } - } - } - if (app.sfiFiles.isNotEmpty()) { - item { - title = "SFI Files" - for ((sfi, file) in app.sfiFiles.entries.sortedBy { it.key }) { - item { - title = "SFI 0x${sfi.toString(16)}" - if (file.binaryData != null) { - item { title = "Binary Data"; value = file.binaryData } - } - for ((index, record) in file.records.entries.sortedBy { it.key }) { - item { title = "Record $index"; value = record } - } - } - } - } - } - } - } - } -} -``` - -**CEPASCard.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - for (purse in purses) { - item { - title = "Purse ID ${purse.id}" - item { title = "CEPAS Version"; value = purse.cepasVersion } - item { title = "Purse Status"; value = purse.purseStatus } - item { title = "Purse Balance"; value = CurrencyFormatter.formatValue(purse.purseBalance / 100.0, "SGD") } - item { title = "Purse Creation Date"; value = formatDate(Instant.fromEpochMilliseconds(purse.purseCreationDate * 1000L), DateFormatStyle.LONG) } - item { title = "Purse Expiry Date"; value = formatDate(Instant.fromEpochMilliseconds(purse.purseExpiryDate * 1000L), DateFormatStyle.LONG) } - item { title = "Autoload Amount"; value = purse.autoLoadAmount } - item { title = "CAN"; value = purse.can } - item { title = "CSN"; value = purse.csn } - } - item { - title = "Last Transaction Information" - item { title = "TRP"; value = purse.lastTransactionTRP } - item { title = "Credit TRP"; value = purse.lastCreditTransactionTRP } - item { title = "Credit Header"; value = purse.lastCreditTransactionHeader } - item { title = "Debit Options"; value = purse.lastTransactionDebitOptionsByte } - } - item { - title = "Other Purse Information" - item { title = "Logfile Record Count"; value = purse.logfileRecordCount } - item { title = "Issuer Data Length"; value = purse.issuerDataLength } - item { title = "Issuer-specific Data"; value = purse.issuerSpecificData } - } - } -} -``` - -**UltralightCard.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - item { - title = Res.string.ultralight_pages - for (page in pages) { - item { - title = FormattedString(Res.string.ultralight_page_title_format, page.index.toString()) - value = page.data - } - } - } -} -``` - -**VicinityCard.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - if (sysInfo != null) { - item { title = "System Info"; value = sysInfo } - } - item { - title = "Pages" - for (page in pages) { - item { - title = "Page ${page.index}" - value = if (page.isUnauthorized) "Unauthorized" else page.data - } - } - } -} -``` - -**KSX6924PurseInfo.kt:** -```kotlin -suspend fun getAdvancedInfo(resolver: KSX6924PurseInfoResolver = KSX6924PurseInfoDefaultResolver): FareBotUiTree = uiTree { - item { title = Res.string.ksx6924_crypto_algorithm; value = resolver.resolveCryptoAlgo(alg) } - item { title = Res.string.ksx6924_encryption_key_version; value = vk.hexString } - item { title = Res.string.ksx6924_auth_id; value = idtr.hexString } - item { title = Res.string.ksx6924_ticket_type; value = resolver.resolveUserCode(userCode) } - item { title = Res.string.ksx6924_max_balance; value = balMax.toString() } - item { title = Res.string.ksx6924_branch_code; value = bra.hexString } - item { title = Res.string.ksx6924_one_time_limit; value = mmax.toString() } - item { title = Res.string.ksx6924_mobile_carrier; value = resolver.resolveTCode(tcode) } - item { title = Res.string.ksx6924_financial_institution; value = resolver.resolveCCode(ccode) } - item { title = Res.string.ksx6924_rfu; value = rfu.hex() } -} -``` - -For each file, update imports: replace `import com.codebutler.farebot.base.ui.FareBotUiTree` with `import com.codebutler.farebot.base.ui.uiTree` (and keep FareBotUiTree import only if the type is referenced in return type annotations). - -**Step 2: Commit** - -```bash -git add card/ -git commit -m "refactor: convert card getAdvancedUi() from builder to DSL" -``` - ---- - -## Task 5: Convert transit module getAdvancedUi() to DSL - -**Files (12):** -- `transit/ovc/src/commonMain/kotlin/.../OVChipTransitInfo.kt` -- `transit/ovc/src/commonMain/kotlin/.../OVChipIndex.kt` -- `transit/octopus/src/commonMain/kotlin/.../OctopusTransitInfo.kt` -- `transit/smartrider/src/commonMain/kotlin/.../SmartRiderTransitInfo.kt` -- `transit/hsl/src/commonMain/kotlin/.../HSLTransitInfo.kt` -- `transit/charlie/src/commonMain/kotlin/.../CharlieCardTransitInfo.kt` -- `transit/nextfareul/src/commonMain/kotlin/.../NextfareUltralightTransitData.kt` -- `transit/calypso/src/commonMain/kotlin/.../LisboaVivaTransitInfo.kt` -- `transit/serialonly/src/commonMain/kotlin/.../StrelkaTransitInfo.kt` -- `transit/serialonly/src/commonMain/kotlin/.../HoloTransitInfo.kt` -- `transit/umarsh/src/commonMain/kotlin/.../UmarshTransitInfo.kt` -- `transit/troika/src/commonMain/kotlin/.../TroikaHybridTransitInfo.kt` - -**Step 1: Convert OVChipIndex.addAdvancedItems** - -Change from taking `FareBotUiTree.Item.Builder` to returning `List`: - -```kotlin -fun advancedItems(): List = listOf( - FareBotUiTree.Item(title = FormattedString("Transaction Slot"), value = if (recentTransactionSlot) "B" else "A"), - FareBotUiTree.Item(title = FormattedString("Info Slot"), value = if (recentInfoSlot) "B" else "A"), - FareBotUiTree.Item(title = FormattedString("Subscription Slot"), value = if (recentSubscriptionSlot) "B" else "A"), - FareBotUiTree.Item(title = FormattedString("Travelhistory Slot"), value = if (recentTravelhistorySlot) "B" else "A"), - FareBotUiTree.Item(title = FormattedString("Credit Slot"), value = if (recentCreditSlot) "B" else "A"), -) -``` - -**Step 2: Convert OVChipTransitInfo.getAdvancedUi()** - -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - item { title = "Credit Slot ID"; value = creditSlotId.toString() } - item { title = "Last Credit ID"; value = creditId.toString() } - item { - title = "Recent Slots" - addChildren(index.advancedItems()) - } -} -``` - -**Step 3: Convert remaining transit files** - -Each follows the same builder-to-DSL pattern. Specific conversions: - -**OctopusTransitInfo.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree? { - val szt = shenzhenBalance - if (!hasOctopus || szt == null) return null - return uiTree { - item { - title = Res.string.octopus_alternate_purse_balances - item { - title = Res.string.octopus_szt - value = TransitCurrency.CNY(szt).formatCurrencyString(isBalance = true) - } - } - } -} -``` - -**SmartRiderTransitInfo.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - item { title = Res.string.smartrider_ticket_type; value = mTokenType.toString() } - if (mSmartRiderType == SmartRiderType.SMARTRIDER) { - item { title = Res.string.smartrider_autoload_threshold; value = TransitCurrency.AUD(mAutoloadThreshold).formatCurrencyString(true) } - item { title = Res.string.smartrider_autoload_value; value = TransitCurrency.AUD(mAutoloadValue).formatCurrencyString(true) } - } -} -``` - -**HSLTransitInfo.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree? { - val tree = uiTree { - applicationVersion?.let { item { title = Res.string.hsl_application_version; value = it } } - applicationKeyVersion?.let { item { title = Res.string.hsl_application_key_version; value = it } } - platformType?.let { item { title = Res.string.hsl_platform_type; value = it } } - securityLevel?.let { item { title = Res.string.hsl_security_level; value = it } } - } - return if (tree.items.isEmpty()) null else tree -} -``` - -**CharlieCardTransitInfo.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree? { - if (secondSerial == 0L || secondSerial == 0xffffffffL) return null - return uiTree { - item { title = Res.string.charlie_2nd_card_number; value = "A" + NumberUtils.zeroPad(secondSerial, 10) } - } -} -``` - -**NextfareUltralightTransitData.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - item { title = Res.string.nextfareul_machine_code; value = capsule.mMachineCode.toString(16) } -} -``` - -**LisboaVivaTransitInfo.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree? { - if (tagId == null) return null - return uiTree { - item { title = Res.string.calypso_engraved_serial; value = tagId.toString() } - } -} -``` - -**StrelkaTransitInfo.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - item { title = Res.string.strelka_long_serial; value = mSerial } -} -``` - -**HoloTransitInfo.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree = uiTree { - item { title = Res.string.manufacture_id; value = mManufacturingId } -} -``` - -**UmarshTransitInfo.kt:** -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree? { - val rubSectors = sectors.filter { it.denomination == UmarshDenomination.RUB } - if (rubSectors.isEmpty()) return null - return uiTree { - for (sec in rubSectors) { - item { title = Res.string.umarsh_machine_id; value = sec.machineId.toString() } - } - } -} -``` - -**TroikaHybridTransitInfo.kt** — flattens sub-trees: -```kotlin -override suspend fun getAdvancedUi(): FareBotUiTree? { - val trees = listOfNotNull( - troika.getAdvancedUi(), - podorozhnik?.getAdvancedUi(), - strelka?.getAdvancedUi(), - ) - if (trees.isEmpty()) return null - return FareBotUiTree(items = trees.flatMap { tree -> - tree.items.map { FareBotUiTree.Item(title = it.title, value = it.value) } - }) -} -``` - -**Step 4: Commit** - -```bash -git add transit/ -git commit -m "refactor: convert transit getAdvancedUi() from builder to DSL" -``` - ---- - -## Task 6: Remove ClipperTrip.Builder - -**Files:** -- Modify: `transit/clipper/src/commonMain/kotlin/.../ClipperTrip.kt` -- Modify: `transit/clipper/src/commonMain/kotlin/.../ClipperTransitFactory.kt` -- Modify: `app/src/commonTest/kotlin/.../ClipperTransitTest.kt` - -**Step 1: Add default values to ClipperTrip constructor, remove Builder** - -Add `= 0` defaults to all constructor parameters (vehicleNum and transportCode already have them). Remove the `Builder` class and `companion object`: - -```kotlin -class ClipperTrip( - private val timestamp: Long = 0, - private val exitTimestampValue: Long = 0, - private val balance: Long = 0, - private val fareValue: Long = 0, - private val agency: Long = 0, - private val from: Long = 0, - private val to: Long = 0, - private val route: Long = 0, - private val vehicleNum: Long = 0, - private val transportCode: Long = 0, -) : Trip() { - // ... all properties and methods stay the same ... - // DELETE: companion object { fun builder() } - // DELETE: class Builder { ... } -} -``` - -**Step 2: Update ClipperTransitFactory.createTrip()** - -Replace: -```kotlin -return ClipperTrip - .builder() - .timestamp(timestamp) - .exitTimestamp(exitTimestamp) - // ... - .build() -``` -with: -```kotlin -return ClipperTrip( - timestamp = timestamp, - exitTimestampValue = exitTimestamp, - balance = 0, - fareValue = fare, - agency = agency, - from = from, - to = to, - route = route, - vehicleNum = vehicleNum, - transportCode = transportCode, -) -``` - -**Step 3: Update ClipperTransitTest.kt** - -Replace all `ClipperTrip.builder().agency(x).transportCode(y).build()` with constructor calls: -```kotlin -// Before: -val trip = ClipperTrip.builder().agency(0x04).transportCode(0x6f).build() -// After: -val trip = ClipperTrip(agency = 0x04, transportCode = 0x6f) -``` - -Apply this to every test method (~15 call sites). - -**Step 4: Commit** - -```bash -git add transit/clipper/ app/src/commonTest/ -git commit -m "refactor: remove ClipperTrip.Builder, use constructor with named params" -``` - ---- - -## Task 7: Remove SeqGoTrip.Builder - -**Files:** -- Modify: `transit/seqgo/src/commonMain/kotlin/.../SeqGoTrip.kt` -- Modify: `transit/seqgo/src/commonMain/kotlin/.../SeqGoTransitFactory.kt` - -**Step 1: Add default values to SeqGoTrip constructor, remove Builder** - -```kotlin -class SeqGoTrip( - private val journeyId: Int = 0, - private val modeValue: Mode = Mode.OTHER, - private val startTime: Instant? = null, - private val endTime: Instant? = null, - private val startStationId: Int = 0, - private val endStationId: Int = 0, - private val startStationValue: Station? = null, - private val endStationValue: Station? = null, -) : Trip() { - // ... all properties and methods stay the same ... - // DELETE: class Builder { ... } - // DELETE: companion object { fun builder() } -} -``` - -**Step 2: Update SeqGoTransitFactory** - -Replace builder usage with direct construction. The tricky part: the builder pattern was used to conditionally add tap-off data. Use `var` locals + copy pattern, or build the trip in one expression: - -```kotlin -val tapOn = sortedTaps[i] -var endTime: Instant? = null -var endStationId = 0 -var endStation: Station? = null - -if (sortedTaps.size > i + 1 && - sortedTaps[i + 1].journey == tapOn.journey && - sortedTaps[i + 1].mode == tapOn.mode -) { - val tapOff = sortedTaps[i + 1] - endTime = tapOff.timestamp - endStationId = tapOff.station - endStation = SeqGoUtil.getStation(tapOff.station) - i++ -} - -trips.add( - SeqGoTrip( - journeyId = tapOn.journey, - modeValue = tapOn.mode, - startTime = tapOn.timestamp, - endTime = endTime, - startStationId = tapOn.station, - endStationId = endStationId, - startStationValue = SeqGoUtil.getStation(tapOn.station), - endStationValue = endStation, - ) -) -``` - -**Step 3: Commit** - -```bash -git add transit/seqgo/ -git commit -m "refactor: remove SeqGoTrip.Builder, use constructor with named params" -``` - ---- - -## Task 8: Remove Station.Builder - -**Files:** -- Modify: `transit/src/commonMain/kotlin/com/codebutler/farebot/transit/Station.kt` - -**Step 1: Remove Builder class and companion factory method** - -Delete the `Builder` class (lines 110-170) and the `builder()` method from the companion object. The `Station` data class already has default values for all constructor parameters. No call sites to update (0 usages). - -**Step 2: Commit** - -```bash -git add transit/src/commonMain/kotlin/com/codebutler/farebot/transit/Station.kt -git commit -m "refactor: remove unused Station.Builder" -``` - ---- - -## Task 9: Build and test - -**Step 1: Run tests** - -```bash -cd /workspace/.worktrees/remove-builders && ./gradlew allTests -``` - -Expected: All tests pass. - -**Step 2: Run full build** - -```bash -cd /workspace/.worktrees/remove-builders && ./gradlew assemble -``` - -Expected: Build succeeds. - -**Step 3: Fix any compilation errors** - -Common issues to watch for: -- Missing imports for `uiTree` or `FormattedString` -- `FareBotUiTree.builder()` references still lingering -- `FareBotUiTree.Item.Builder` type references in function signatures (OVChipIndex) - ---- - -## Task Dependency Graph - -``` -Task 1 (FareBotUiTree data model) - └─ Task 2 (UiTreeBuilder DSL) - ├─ Task 3 (CardAdvancedScreen) - ├─ Task 4 (card modules) ──┐ - └─ Task 5 (transit modules)┤ - ├─ Task 9 (build & test) -Task 6 (ClipperTrip.Builder) ─────┤ -Task 7 (SeqGoTrip.Builder) ───────┤ -Task 8 (Station.Builder) ─────────┘ -``` - -Tasks 6, 7, 8 are independent of tasks 1-5 and can run in parallel. -Tasks 4 and 5 can run in parallel after tasks 1-3 are complete. diff --git a/docs/plans/2026-02-16-webusb-full-card-reading.md b/docs/plans/2026-02-16-webusb-full-card-reading.md deleted file mode 100644 index d8157e59d..000000000 --- a/docs/plans/2026-02-16-webusb-full-card-reading.md +++ /dev/null @@ -1,1007 +0,0 @@ -# Full Card Reading over WebUSB — Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Enable full NFC card data reading (not just detection) over WebUSB in the Kotlin/WasmJs web target. - -**Architecture:** The core problem is a sync/async mismatch: all NFC technology interfaces (`CardTransceiver.transceive()`, `ClassicTechnology.readBlock()`, etc.) are synchronous, but WebUSB is Promise-based and Kotlin/WasmJs cannot block on Promises. The solution is to make these interfaces `suspend`-compatible. This is the architecturally correct approach — NFC I/O is inherently async, and all platforms already call card readers from async contexts (coroutines, threads, GCD queues). Adding `suspend` to non-suspending implementations has zero runtime overhead. - -**Tech Stack:** Kotlin Multiplatform, Kotlin Coroutines, WebUSB (JS interop), PN533 NFC protocol - ---- - -## Architecture Overview - -The call chain from card detection to data is: - -``` -WebCardScanner.pollLoop() [suspend, web-specific] - → Card readers: DesfireCardReader, ClassicCardReader, UltralightCardReader, FeliCaReader, etc. - → Technology interfaces: CardTransceiver.transceive(), ClassicTechnology.readBlock(), etc. - → PN533 controller: PN533.inDataExchange(), PN533.inCommunicateThru() - → Transport: PN533Transport.sendCommand() - → WebUsbPN533Transport: currently throws "cannot call synchronously" -``` - -We make every layer in this chain `suspend`, bottom-up. Existing sync implementations (Android, iOS, Desktop) simply add the `suspend` keyword with no behavior change. WebUSB implementations delegate to their existing async methods. - -**Files NOT changed:** Transit system implementations (`transit/*/`), UI code, ViewModels, persistence, MDST lookups. The change is entirely within the NFC I/O pipeline. - -**Vicinity (NFC-V/ISO 15693)** is excluded from WebUSB card reading because PN533 readers don't support ISO 15693. The `VicinityTechnology` interface still gets `suspend` for consistency, but no web implementation is created. - ---- - -### Task 1: Make PN533Transport and PN533 suspend-compatible - -This is the foundation. Make the transport interface and PN533 controller suspend-compatible, and implement WebUsbPN533Transport.sendCommand() properly. - -**Files:** -- Modify: `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Transport.kt` -- Modify: `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533.kt` -- Modify: `card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt` -- Modify: `card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/pn533/Usb4JavaPN533Transport.kt` (add `suspend` keyword only) - -**Step 1: Make PN533Transport interface suspend** - -In `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Transport.kt`, change: - -```kotlin -interface PN533Transport { - suspend fun sendCommand( - code: Byte, - data: ByteArray = byteArrayOf(), - timeoutMs: Int = 5000, - ): ByteArray - - suspend fun sendAck() - - fun flush() - - fun close() -} -``` - -`flush()` and `close()` stay non-suspend (they're setup/teardown, not I/O exchange). - -**Step 2: Make PN533 class methods suspend** - -In `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533.kt`, add `suspend` to every method that calls `transport.sendCommand()` or `transport.sendAck()`: - -- `getFirmwareVersion()` → `suspend fun getFirmwareVersion()` -- `samConfiguration()` → `suspend fun samConfiguration()` -- `setParameters()` → `suspend fun setParameters()` -- `resetMode()` → `suspend fun resetMode()` -- `writeRegister()` → `suspend fun writeRegister()` -- `rfConfiguration()` → `suspend fun rfConfiguration()` -- `setMaxRetries()` → `suspend fun setMaxRetries()` -- `rfFieldOff()` → `suspend fun rfFieldOff()` -- `rfFieldOn()` → `suspend fun rfFieldOn()` -- `inListPassiveTarget()` → `suspend fun inListPassiveTarget()` -- `inDataExchange()` → `suspend fun inDataExchange()` -- `inCommunicateThru()` → `suspend fun inCommunicateThru()` -- `inRelease()` → `suspend fun inRelease()` -- `sendAck()` → `suspend fun sendAck()` - -`close()` stays non-suspend. - -**Step 3: Implement WebUsbPN533Transport.sendCommand() properly** - -In `card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt`, change `sendCommand()` from throwing an error to delegating to `sendCommandAsync()`: - -```kotlin -override suspend fun sendCommand( - code: Byte, - data: ByteArray, - timeoutMs: Int, -): ByteArray = sendCommandAsync(code, data, timeoutMs) - -override suspend fun sendAck() = sendAckAsync() -``` - -**Step 4: Add suspend to Usb4JavaPN533Transport** - -In the JVM transport implementation, just add `suspend` keyword to `sendCommand()` and `sendAck()`. No logic change. - -**Step 5: Build the card module to verify** - -Run: `./gradlew :card:compileCommonMainKotlinMetadata` - -Expected: Compilation errors in callers of PN533 that haven't been updated yet (technology implementations). This is expected — we fix those in Task 3. - -**Step 6: Commit** - -```bash -git add card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Transport.kt \ - card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533.kt \ - card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt \ - card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/pn533/Usb4JavaPN533Transport.kt -git commit -m "refactor: make PN533Transport and PN533 suspend-compatible - -Adds suspend to PN533Transport.sendCommand() and sendAck(), and -propagates through all PN533 controller methods. WebUsbPN533Transport -now delegates sendCommand() to sendCommandAsync() instead of throwing." -``` - ---- - -### Task 2: Make NFC technology interfaces suspend-compatible - -Add `suspend` to the NFC technology interface methods that perform I/O. `NfcTechnology.connect()` and `close()` stay non-suspend. - -**Files:** -- Modify: `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/CardTransceiver.kt` -- Modify: `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/ClassicTechnology.kt` -- Modify: `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/UltralightTechnology.kt` -- Modify: `card/felica/src/commonMain/kotlin/com/codebutler/farebot/card/felica/FeliCaTagAdapter.kt` -- Modify: `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/VicinityTechnology.kt` - -**Step 1: Update CardTransceiver** - -```kotlin -interface CardTransceiver : NfcTechnology { - suspend fun transceive(data: ByteArray): ByteArray - - val maxTransceiveLength: Int -} -``` - -**Step 2: Update ClassicTechnology** - -```kotlin -interface ClassicTechnology : NfcTechnology { - val sectorCount: Int - - suspend fun authenticateSectorWithKeyA(sectorIndex: Int, key: ByteArray): Boolean - - suspend fun authenticateSectorWithKeyB(sectorIndex: Int, key: ByteArray): Boolean - - suspend fun readBlock(blockIndex: Int): ByteArray - - fun sectorToBlock(sectorIndex: Int): Int - - fun getBlockCountInSector(sectorIndex: Int): Int - // ... companion object unchanged -} -``` - -`sectorToBlock()` and `getBlockCountInSector()` are pure computation — no suspend needed. - -**Step 3: Update UltralightTechnology** - -```kotlin -interface UltralightTechnology : NfcTechnology { - val type: Int - - suspend fun readPages(pageOffset: Int): ByteArray - - suspend fun transceive(data: ByteArray): ByteArray - - fun reconnect() { /* default no-op */ } -} -``` - -**Step 4: Update FeliCaTagAdapter** - -```kotlin -interface FeliCaTagAdapter { - fun getIDm(): ByteArray // stays non-suspend (returns cached value) - - suspend fun getSystemCodes(): List - - suspend fun selectSystem(systemCode: Int): ByteArray? - - suspend fun getServiceCodes(): List - - suspend fun readBlock(serviceCode: Int, blockAddr: Byte): ByteArray? -} -``` - -**Step 5: Update VicinityTechnology** - -```kotlin -interface VicinityTechnology : NfcTechnology { - val uid: ByteArray - - suspend fun transceive(data: ByteArray): ByteArray -} -``` - -**Step 6: Commit** - -```bash -git add card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/CardTransceiver.kt \ - card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/ClassicTechnology.kt \ - card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/UltralightTechnology.kt \ - card/felica/src/commonMain/kotlin/com/codebutler/farebot/card/felica/FeliCaTagAdapter.kt \ - card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/VicinityTechnology.kt -git commit -m "refactor: make NFC technology interfaces suspend-compatible - -CardTransceiver.transceive(), ClassicTechnology.readBlock()/auth, -UltralightTechnology.readPages()/transceive(), FeliCaTagAdapter I/O -methods, and VicinityTechnology.transceive() are now suspend functions." -``` - ---- - -### Task 3: Update all technology implementations - -Add `suspend` keyword to all implementations of the interfaces changed in Task 2. No logic changes — purely mechanical. - -**Files:** -- Modify: `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533CardTransceiver.kt` -- Modify: `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533CommunicateThruTransceiver.kt` -- Modify: `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533ClassicTechnology.kt` -- Modify: `card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533UltralightTechnology.kt` -- Modify: `card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/PCSCCardTransceiver.kt` -- Modify: JVM PCSC Classic/Ultralight technology implementations (find with `grep -r "ClassicTechnology\|UltralightTechnology" card/src/jvmMain/`) -- Modify: `card/src/androidMain/kotlin/com/codebutler/farebot/card/nfc/AndroidCardTransceiver.kt` -- Modify: `card/src/androidMain/kotlin/com/codebutler/farebot/card/nfc/AndroidUltralightTechnology.kt` -- Modify: `card/src/androidMain/kotlin/com/codebutler/farebot/card/nfc/AndroidVicinityTechnology.kt` -- Modify: `card/felica/src/androidMain/kotlin/com/codebutler/farebot/card/felica/AndroidFeliCaTagAdapter.kt` -- Modify: `card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosCardTransceiver.kt` -- Modify: iOS Ultralight/Vicinity technology implementations -- Modify: `card/felica/src/iosMain/kotlin/com/codebutler/farebot/card/felica/IosFeliCaTagAdapter.kt` -- Modify: JVM PCSC FeliCa adapter (`card/felica/src/jvmMain/`) - -**Step 1: Update PN533 technology implementations** - -For each file, add `suspend` to the overriding methods. Example for `PN533CardTransceiver`: - -```kotlin -override suspend fun transceive(data: ByteArray): ByteArray = pn533.inDataExchange(tg, data) -``` - -For `PN533CommunicateThruTransceiver`: - -```kotlin -override suspend fun transceive(data: ByteArray): ByteArray { - // ... existing logic unchanged, just add suspend -} -``` - -For `PN533ClassicTechnology`: - -```kotlin -override suspend fun authenticateSectorWithKeyA(sectorIndex: Int, key: ByteArray): Boolean = - authenticate(sectorIndex, key, MIFARE_CMD_AUTH_A) - -override suspend fun authenticateSectorWithKeyB(sectorIndex: Int, key: ByteArray): Boolean = - authenticate(sectorIndex, key, MIFARE_CMD_AUTH_B) - -override suspend fun readBlock(blockIndex: Int): ByteArray = ... - -private suspend fun authenticate(...): Boolean = ... // calls pn533.inDataExchange -``` - -For `PN533UltralightTechnology`: - -```kotlin -override suspend fun readPages(pageOffset: Int): ByteArray = ... -override suspend fun transceive(data: ByteArray): ByteArray = ... -``` - -**Step 2: Update platform technology implementations (Android, iOS, JVM/PCSC)** - -Same mechanical change: add `suspend` to each overriding method. Use the compiler to find every implementation that needs updating: - -```bash -./gradlew :card:compileCommonMainKotlinMetadata 2>&1 | grep "error:" -``` - -Fix each error by adding `suspend`. - -**Step 3: Commit** - -```bash -git add -A card/src/ card/felica/src/ -git commit -m "refactor: add suspend to all NFC technology implementations - -Mechanical change: adds suspend keyword to all implementations of -CardTransceiver, ClassicTechnology, UltralightTechnology, -FeliCaTagAdapter, and VicinityTechnology across PN533, PCSC, -Android, and iOS source sets." -``` - ---- - -### Task 4: Move PN533FeliCaTagAdapter to commonMain - -Currently in `card/felica/src/jvmMain/`. Needed in commonMain for web to use it. The class has no JVM-specific dependencies — it only uses `PN533` (commonMain) and `FeliCaTagAdapter` (commonMain). - -**Files:** -- Move: `card/felica/src/jvmMain/kotlin/com/codebutler/farebot/card/felica/PN533FeliCaTagAdapter.kt` → `card/felica/src/commonMain/kotlin/com/codebutler/farebot/card/felica/PN533FeliCaTagAdapter.kt` - -**Step 1: Move the file** - -```bash -mkdir -p card/felica/src/commonMain/kotlin/com/codebutler/farebot/card/felica/ -mv card/felica/src/jvmMain/kotlin/com/codebutler/farebot/card/felica/PN533FeliCaTagAdapter.kt \ - card/felica/src/commonMain/kotlin/com/codebutler/farebot/card/felica/PN533FeliCaTagAdapter.kt -``` - -**Step 2: Add suspend to its methods** - -Update `PN533FeliCaTagAdapter` methods to match the now-suspend `FeliCaTagAdapter` interface: - -```kotlin -override suspend fun getSystemCodes(): List { ... } -override suspend fun selectSystem(systemCode: Int): ByteArray? { ... } -override suspend fun getServiceCodes(): List { ... } -override suspend fun readBlock(serviceCode: Int, blockAddr: Byte): ByteArray? { ... } -``` - -Also update private helpers that call `pn533.inCommunicateThru()`: - -```kotlin -private suspend fun transceiveFelica(felicaFrame: ByteArray): ByteArray? = ... -private suspend fun polling(systemCode: Int): ByteArray? = ... -``` - -**Step 3: Verify the felica module compiles** - -Run: `./gradlew :card:felica:compileCommonMainKotlinMetadata` - -Expected: Success (or errors in callers not yet updated). - -**Step 4: Commit** - -```bash -git add card/felica/src/ -git commit -m "refactor: move PN533FeliCaTagAdapter to commonMain - -No JVM-specific dependencies — now available for all platforms -including wasmJs/web. Methods updated to suspend per FeliCaTagAdapter -interface changes." -``` - ---- - -### Task 5: Make protocol classes suspend-compatible - -Protocol classes sit between card readers and technology interfaces. They call `transceiver.transceive()` which is now suspend, so they must become suspend too. - -**Files:** -- Modify: `card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireProtocol.kt` -- Modify: `card/iso7816/src/commonMain/kotlin/com/codebutler/farebot/card/iso7816/ISO7816Protocol.kt` -- Modify: `card/cepas/src/commonMain/kotlin/com/codebutler/farebot/card/cepas/CEPASProtocol.kt` -- Modify: `card/ultralight/src/commonMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightProtocol.kt` (if it exists) - -**Step 1: Update DesfireProtocol** - -Every method that calls `mTransceiver.transceive()` becomes suspend. This includes: - -- `getManufacturingData()` → `suspend fun getManufacturingData()` -- `getAppList()` → `suspend fun getAppList()` -- `selectApp()` → `suspend fun selectApp()` -- `getFileList()` → `suspend fun getFileList()` -- `getFileSettings()` → `suspend fun getFileSettings()` -- `readFile()` → `suspend fun readFile()` -- `readRecord()` → `suspend fun readRecord()` -- `getValue()` → `suspend fun getValue()` -- All `sendRequest()` overloads → `private suspend fun sendRequest()` - -**Step 2: Update ISO7816Protocol** - -All methods that call `transceiver.transceive()`: - -- `selectByName()` → `suspend fun selectByName()` -- `selectById()` → `suspend fun selectById()` -- `readBinary()` → `suspend fun readBinary()` -- `readRecord()` → `suspend fun readRecord()` -- `getBalance()` → `suspend fun getBalance()` -- All internal helper methods - -**Step 3: Update CEPASProtocol** - -- `getPurse()` → `suspend fun getPurse()` -- `getHistory()` → `suspend fun getHistory()` -- Any internal methods - -**Step 4: Update UltralightProtocol (if exists)** - -Check for a protocol class in `card/ultralight/`. If it calls `tech.readPages()` or `tech.transceive()`, make those calls suspend. - -**Step 5: Build to verify** - -Run: `./gradlew :card:desfire:compileCommonMainKotlinMetadata :card:iso7816:compileCommonMainKotlinMetadata :card:cepas:compileCommonMainKotlinMetadata` - -Expected: Errors in card readers (not yet updated). Protocol classes should compile. - -**Step 6: Commit** - -```bash -git add card/desfire/src/ card/iso7816/src/ card/cepas/src/ card/ultralight/src/ -git commit -m "refactor: make protocol classes suspend-compatible - -DesfireProtocol, ISO7816Protocol, CEPASProtocol, and UltralightProtocol -now use suspend functions for NFC I/O operations." -``` - ---- - -### Task 6: Make card readers and ISO7816Dispatcher suspend-compatible - -Card readers call protocol classes (now suspend) and technology interfaces (now suspend). Make all their methods suspend. - -**Files:** -- Modify: `card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireCardReader.kt` -- Modify: `card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt` -- Modify: `card/ultralight/src/commonMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightCardReader.kt` -- Modify: `card/felica/src/commonMain/kotlin/com/codebutler/farebot/card/felica/FeliCaReader.kt` -- Modify: `card/cepas/src/commonMain/kotlin/com/codebutler/farebot/card/cepas/CEPASCardReader.kt` -- Modify: `card/iso7816/src/commonMain/kotlin/com/codebutler/farebot/card/iso7816/ISO7816CardReader.kt` -- Modify: `card/vicinity/src/commonMain/kotlin/com/codebutler/farebot/card/vicinity/VicinityCardReader.kt` -- Modify: `app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/ISO7816Dispatcher.kt` - -**Step 1: Update DesfireCardReader** - -```kotlin -object DesfireCardReader { - suspend fun readCard(tagId: ByteArray, tech: CardTransceiver): RawDesfireCard { ... } - private suspend fun readApplications(...): List { ... } - private suspend fun readFiles(...): Pair, Boolean> { ... } - private suspend fun readFile(...): RawDesfireFile { ... } - private suspend fun tryReadFileWithoutSettings(...): RawDesfireFile { ... } - private suspend fun readFileData(...): ByteArray { ... } -} -``` - -**Step 2: Update ClassicCardReader** - -```kotlin -object ClassicCardReader { - suspend fun readCard(tagId: ByteArray, tech: ClassicTechnology, cardKeys: ClassicCardKeys?): RawClassicCard { ... } - // All private helper methods that call tech.authenticateSectorWithKeyA/B() or tech.readBlock() -} -``` - -**Step 3: Update UltralightCardReader** - -```kotlin -object UltralightCardReader { - suspend fun readCard(tagId: ByteArray, tech: UltralightTechnology): RawUltralightCard { ... } - // All private helpers -} -``` - -**Step 4: Update FeliCaReader** - -```kotlin -object FeliCaReader { - suspend fun readTag(tagId: ByteArray, adapter: FeliCaTagAdapter, onlyFirst: Boolean = false): RawFelicaCard { ... } - // All private helpers -} -``` - -**Step 5: Update CEPASCardReader** - -```kotlin -object CEPASCardReader { - suspend fun readCard(tagId: ByteArray, tech: CardTransceiver): RawCEPASCard { ... } -} -``` - -**Step 6: Update ISO7816CardReader** - -```kotlin -object ISO7816CardReader { - suspend fun readCard(tagId: ByteArray, transceiver: CardTransceiver, appConfigs: List): RawISO7816Card { ... } - // All internal methods - // AppConfig lambdas (readBalances, readExtraData) must also become suspend lambdas: - // readBalances: suspend (ISO7816Protocol) -> Map - // readExtraData: suspend (ISO7816Protocol) -> Map -} -``` - -**Step 7: Update VicinityCardReader** - -```kotlin -object VicinityCardReader { - suspend fun readCard(tagId: ByteArray, tech: VicinityTechnology): RawVicinityCard { ... } -} -``` - -**Step 8: Update ISO7816Dispatcher** - -```kotlin -object ISO7816Dispatcher { - suspend fun readCard(tagId: ByteArray, transceiver: CardTransceiver): RawCard<*> { ... } - private suspend fun tryISO7816(...): RawCard<*>? { ... } - // Update buildAppConfigs() — the lambdas need to be suspend -} -``` - -The `buildAppConfigs()` method returns `AppConfig` objects with lambda fields. If `readBalances` and `readExtraData` are called from suspend context, their function types need to become suspend: - -```kotlin -data class AppConfig( - val appNames: List, - val type: String, - val readBalances: (suspend (ISO7816Protocol) -> Map)? = null, - val readExtraData: (suspend (ISO7816Protocol) -> Map)? = null, - val fileSelectors: List = emptyList(), -) -``` - -**Step 9: Build all card modules** - -Run: `./gradlew compileCommonMainKotlinMetadata` - -Expected: Errors in platform call sites (Desktop, Android, iOS). Card modules should compile. - -**Step 10: Commit** - -```bash -git add card/ app/src/commonMain/ -git commit -m "refactor: make card readers and ISO7816Dispatcher suspend-compatible - -All card reader public APIs (readCard/readTag) are now suspend functions. -ISO7816CardReader.AppConfig lambdas updated to suspend function types." -``` - ---- - -### Task 7: Update platform call sites - -Each platform calls card readers from different contexts. Update each to work with the now-suspend card reader APIs. - -**Files:** -- Modify: `app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt` -- Modify: `app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt` -- Modify: `app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/RCS956ReaderBackend.kt` (if it calls card readers) -- Modify: `app/src/androidMain/kotlin/com/codebutler/farebot/app/feature/home/AndroidCardScanner.kt` -- Modify: Android tag reader classes (if they call card readers directly) -- Modify: `app/src/iosMain/kotlin/com/codebutler/farebot/shared/nfc/IosNfcScanner.kt` - -**Step 1: Update Desktop — PN53xReaderBackend** - -The desktop poll loop runs on a dedicated thread. Wrap card reading calls in `runBlocking`: - -```kotlin -import kotlinx.coroutines.runBlocking - -// In pollLoop(), change the readTarget call: -try { - val rawCard = runBlocking { readTarget(pn533, target) } - onCardRead(rawCard) -} catch (e: Exception) { ... } - -// Make readTarget suspend: -private suspend fun readTarget(pn533: PN533, target: PN533.TargetInfo): RawCard<*> = ... - -// readTypeACard, readFeliCaCard become suspend -private suspend fun readTypeACard(...): RawCard<*> { ... } -private suspend fun readFeliCaCard(...): RawCard<*> { ... } -``` - -Also update `initDevice(pn533)` call — if it calls PN533 methods (which are now suspend), wrap in `runBlocking`: - -```kotlin -// In scanLoop(): -runBlocking { initDevice(pn533) } -``` - -Or make `initDevice` suspend and wrap the whole poll block. - -The simplest approach: make `pollLoop` a suspend function and wrap the entire `scanLoop` body in `runBlocking`: - -```kotlin -override fun scanLoop(...) { - val transport = ... - transport.flush() - val pn533 = PN533(transport) - try { - runBlocking { - initDevice(pn533) - pollLoop(pn533, onCardDetected, onCardRead, onError) - } - } finally { - pn533.close() - } -} - -private suspend fun pollLoop(...) { - while (true) { - // ... existing logic, now suspend-compatible - // Replace Thread.sleep with delay: - delay(POLL_INTERVAL_MS) - } -} -``` - -**Step 2: Update Desktop — PcscReaderBackend** - -Similar approach — wrap card reading in `runBlocking`. The PCSC backend also runs on a thread, so `runBlocking` is safe. - -**Step 3: Update Desktop — RCS956ReaderBackend and PN533ReaderBackend** - -Check if these are subclasses of `PN53xReaderBackend`. If so, they may just need `initDevice()` to become suspend: - -```kotlin -override suspend fun initDevice(pn533: PN533) { ... } -``` - -**Step 4: Update Android — AndroidCardScanner** - -Android already runs in a coroutine (`scope.launch { ... }`). The tag reader factory call needs to be suspend. Check `tagReaderFactory.getTagReader(tag.id, tag, cardKeys).readTag()`: - -- If `readTag()` calls card readers, it needs to be suspend -- Trace through to find all Android tag reader classes - -The key Android tag readers to check: -- `card/desfire/src/androidMain/kotlin/com/codebutler/farebot/card/desfire/DesfireTagReader.kt` -- `card/cepas/src/androidMain/kotlin/com/codebutler/farebot/card/cepas/CEPASTagReader.kt` -- `app/src/androidMain/kotlin/com/codebutler/farebot/app/core/nfc/ISO7816TagReader.kt` - -Make their `readTag()` methods suspend. Since `AndroidCardScanner` already calls from a coroutine, this should be straightforward. - -**Step 5: Update iOS — IosNfcScanner** - -iOS runs card reading on a GCD worker queue using `dispatch_async(workerQueue)`. The reading happens synchronously on that queue. Use `runBlocking` to bridge: - -```kotlin -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 readFelicaTag(tag: NFCFeliCaTagProtocol): RawCard<*> { ... } -private suspend fun readMiFareTag(tag: NFCMiFareTagProtocol): RawCard<*> { ... } -private suspend fun readVicinityTag(tag: NFCISO15693TagProtocol): RawCard<*> { ... } -``` - -**Step 6: Build all platforms** - -Run: `./gradlew compileCommonMainKotlinMetadata` - -Then try platform-specific builds: -- `./gradlew :app:android:assembleDebug` (may not work in devcontainer) -- `./gradlew :app:desktop:compileKotlinJvm` - -Fix any remaining compilation errors. - -**Step 7: Commit** - -```bash -git add app/ -git commit -m "refactor: update platform call sites for suspend card readers - -Desktop: wrap card reading in runBlocking on poll thread. -Android: propagate suspend through tag reader chain. -iOS: use runBlocking on GCD worker queue." -``` - ---- - -### Task 8: Update tests - -Tests use mock implementations of the NFC technology interfaces. These mocks need `suspend` on their overriding methods. Test functions that call card readers need to use `runTest`. - -**Files:** -- Modify: `card/desfire/src/commonTest/kotlin/com/codebutler/farebot/card/desfire/DesfireProtocolTest.kt` -- Modify: `card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/ClassicCardReaderTest.kt` -- Modify: `card/ultralight/src/commonTest/kotlin/com/codebutler/farebot/card/ultralight/UltralightCardReaderTest.kt` -- Modify: `card/felica/src/commonTest/kotlin/com/codebutler/farebot/card/felica/FeliCaReaderTest.kt` -- Modify: `card/iso7816/src/commonTest/kotlin/com/codebutler/farebot/card/iso7816/ISO7816ProtocolTest.kt` -- Modify: `card/iso7816/src/commonTest/kotlin/com/codebutler/farebot/card/iso7816/ISO7816CardReaderTest.kt` -- Modify: `card/vicinity/src/commonTest/kotlin/com/codebutler/farebot/card/vicinity/VicinityCardReaderTest.kt` - -**Step 1: Update mock implementations** - -In each test file, find mock classes that implement `CardTransceiver`, `ClassicTechnology`, etc. and add `suspend` to their overriding methods: - -```kotlin -// Example: DesfireProtocolTest.MockTransceiver -private class MockTransceiver : CardTransceiver { - override suspend fun transceive(data: ByteArray): ByteArray = responses.removeFirst() - // ... -} -``` - -**Step 2: Wrap test functions in runTest** - -Tests that call suspend card readers need `runTest`: - -```kotlin -import kotlinx.coroutines.test.runTest - -@Test -fun testReadCard() = runTest { - val result = DesfireCardReader.readCard(tagId, mockTransceiver) - // assertions... -} -``` - -If `kotlinx-coroutines-test` is not already a test dependency, add it: - -```kotlin -// In relevant build.gradle.kts files: -commonTest { - dependencies { - implementation(kotlin("test")) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") - } -} -``` - -**Step 3: Run all tests** - -Run: `./gradlew allTests` - -Expected: All existing tests pass (behavior unchanged, only API surface changed to suspend). - -**Step 4: Commit** - -```bash -git add card/ -git commit -m "test: update test mocks and assertions for suspend interfaces - -Mock NFC technology implementations now use suspend. Test functions -that call card readers wrapped in runTest." -``` - ---- - -### Task 9: Implement full card reading in WebCardScanner - -This is the payoff — replace the "card reading not supported" error with actual card reading logic. - -**Files:** -- Modify: `app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt` - -**Step 1: Add imports for card readers and technology adapters** - -```kotlin -import com.codebutler.farebot.card.CardType -import com.codebutler.farebot.card.RawCard -import com.codebutler.farebot.card.cepas.CEPASCardReader -import com.codebutler.farebot.card.classic.ClassicCardReader -import com.codebutler.farebot.card.felica.FeliCaReader -import com.codebutler.farebot.card.felica.PN533FeliCaTagAdapter -import com.codebutler.farebot.card.nfc.pn533.PN533 -import com.codebutler.farebot.card.nfc.pn533.PN533CardInfo -import com.codebutler.farebot.card.nfc.pn533.PN533CardTransceiver -import com.codebutler.farebot.card.nfc.pn533.PN533ClassicTechnology -import com.codebutler.farebot.card.nfc.pn533.PN533CommunicateThruTransceiver -import com.codebutler.farebot.card.nfc.pn533.PN533UltralightTechnology -import com.codebutler.farebot.card.ultralight.UltralightCardReader -import com.codebutler.farebot.shared.nfc.ISO7816Dispatcher -``` - -**Step 2: Create a WebPN533 wrapper** - -The `PN533` class takes a `PN533Transport`. Since `WebUsbPN533Transport` now properly implements the suspend `sendCommand()`, we can use `PN533(transport)` directly. However, the initialization in `pollLoop()` currently calls `transport.sendCommandAsync()` directly. Refactor to use `PN533`: - -```kotlin -private suspend fun pollLoop(transport: WebUsbPN533Transport) { - val pn533 = PN533(transport) - - // Initialize PN533 - pn533.sendAck() - val fw = pn533.getFirmwareVersion() - println("[WebUSB] PN53x firmware: $fw") - pn533.samConfiguration() - pn533.setMaxRetries() - - while (true) { - var target = pn533.inListPassiveTarget(baudRate = PN533.BAUD_RATE_106_ISO14443A) - - if (target == null) { - target = pn533.inListPassiveTarget( - baudRate = PN533.BAUD_RATE_212_FELICA, - initiatorData = SENSF_REQ, - ) - } - - if (target == null) { - delay(POLL_INTERVAL_MS) - continue - } - - val tagId = when (target) { - is PN533.TargetInfo.TypeA -> target.uid - is PN533.TargetInfo.FeliCa -> target.idm - } - val cardTypeName = when (target) { - is PN533.TargetInfo.TypeA -> PN533CardInfo.fromTypeA(target).cardType.name - is PN533.TargetInfo.FeliCa -> CardType.FeliCa.name - } - - _scannedTags.tryEmit(ScannedTag(id = tagId, techList = listOf(cardTypeName))) - - // Full card reading! - try { - val rawCard = readTarget(pn533, target) - _scannedCards.tryEmit(rawCard) - } catch (e: Exception) { - println("[WebUSB] Read error: ${e.message}") - _scanErrors.tryEmit(e) - } - - // Release target - try { - pn533.inRelease(target.tg) - } catch (_: PN533Exception) {} - - // Wait for card removal - waitForRemoval(pn533) - } -} -``` - -**Step 3: Implement readTarget** - -Mirror the desktop `PN53xReaderBackend.readTarget()` logic: - -```kotlin -private suspend fun readTarget( - pn533: PN533, - target: PN533.TargetInfo, -): RawCard<*> = when (target) { - is PN533.TargetInfo.TypeA -> readTypeACard(pn533, target) - is PN533.TargetInfo.FeliCa -> readFeliCaCard(pn533, target) -} - -private suspend fun readTypeACard( - pn533: PN533, - target: PN533.TargetInfo.TypeA, -): RawCard<*> { - val info = PN533CardInfo.fromTypeA(target) - val tagId = target.uid - println("[WebUSB] Type A card: type=${info.cardType}, SAK=0x${(target.sak.toInt() and 0xFF).toString(16).padStart(2, '0')}") - - return when (info.cardType) { - CardType.MifareDesfire, CardType.ISO7816 -> { - val transceiver = PN533CardTransceiver(pn533, target.tg) - ISO7816Dispatcher.readCard(tagId, transceiver) - } - - CardType.MifareClassic -> { - val tech = PN533ClassicTechnology(pn533, target.tg, tagId, info) - ClassicCardReader.readCard(tagId, tech, null) - } - - CardType.MifareUltralight -> { - val tech = PN533UltralightTechnology(pn533, target.tg, info) - UltralightCardReader.readCard(tagId, tech) - } - - CardType.CEPAS -> { - val transceiver = PN533CardTransceiver(pn533, target.tg) - CEPASCardReader.readCard(tagId, transceiver) - } - - else -> { - val transceiver = PN533CardTransceiver(pn533, target.tg) - ISO7816Dispatcher.readCard(tagId, transceiver) - } - } -} - -private suspend fun readFeliCaCard( - pn533: PN533, - target: PN533.TargetInfo.FeliCa, -): RawCard<*> { - val tagId = target.idm - println("[WebUSB] FeliCa card: IDm=${tagId.hex()}") - val adapter = PN533FeliCaTagAdapter(pn533, tagId) - return FeliCaReader.readTag(tagId, adapter) -} -``` - -**Step 4: Update waitForRemoval to use PN533** - -```kotlin -private suspend fun waitForRemoval(pn533: PN533) { - while (true) { - delay(REMOVAL_POLL_INTERVAL_MS) - val target = try { - pn533.inListPassiveTarget(baudRate = PN533.BAUD_RATE_106_ISO14443A) - ?: pn533.inListPassiveTarget( - baudRate = PN533.BAUD_RATE_212_FELICA, - initiatorData = SENSF_REQ, - ) - } catch (_: PN533Exception) { - null - } - if (target == null) break - try { - pn533.inRelease(target.tg) - } catch (_: PN533Exception) {} - } -} -``` - -**Step 5: Remove the now-unnecessary duplicate helper methods** - -Remove `inListPassiveTarget()`, `parseTypeATarget()`, `parseFeliCaTarget()` from WebCardScanner since we now use `PN533.inListPassiveTarget()` directly (which already has these methods). - -**Step 6: Remove the "not yet supported" error** - -Delete the code block that emits `UnsupportedOperationException("Detected ... card reading over WebUSB is in development...")`. - -**Step 7: Commit** - -```bash -git add app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt -git commit -m "feat(web): implement full card reading over WebUSB - -Replaces the detection-only stub with complete card reading for all -card types supported by PN533 USB readers: DESFire, MIFARE Classic, -MIFARE Ultralight, FeliCa, CEPAS, and ISO 7816. - -Uses the same card reader pipeline as desktop/Android/iOS, now -possible because all NFC I/O interfaces are suspend-compatible." -``` - ---- - -### Task 10: Build verification and cleanup - -**Step 1: Run all tests** - -Run: `./gradlew allTests` - -Expected: All tests pass. - -**Step 2: Build web target** - -Run: `./gradlew :app:web:wasmJsBrowserDistribution` - -Expected: Successful build producing the web distribution. - -**Step 3: Build Android (if possible in devcontainer)** - -Run: `./gradlew :app:android:assembleDebug` - -Expected: Successful build (confirms Android suspend changes are correct). - -**Step 4: Build desktop** - -Run: `./gradlew :app:desktop:jar` or `./gradlew :app:desktop:compileKotlinJvm` - -Expected: Successful build. - -**Step 5: Update WEB-REMAINING-WORK.md** - -Mark item 1 (Full card reading over WebUSB) as complete: - -```markdown -### 1. ~~Full card reading over WebUSB~~ DONE -~~Currently only card *detection* works...~~ -Full card reading now works over WebUSB. All card types supported by PN533 readers -(DESFire, Classic, Ultralight, FeliCa, CEPAS, ISO 7816) can be read in the browser. -``` - -**Step 6: Final commit** - -```bash -git add WEB-REMAINING-WORK.md -git commit -m "docs: mark WebUSB full card reading as complete" -``` - ---- - -## Risk Assessment - -| Risk | Mitigation | -|------|------------| -| `runBlocking` deadlock on iOS GCD | iOS already uses `dispatch_semaphore_wait` (blocking), so `runBlocking` on the worker queue is equivalent. Not on main thread. | -| `runBlocking` on Desktop thread | Desktop poll loop already blocks the thread with `Thread.sleep`. `runBlocking` is safe here. | -| `kotlinx-coroutines-test` missing | Check if already a dependency; add if needed (Task 8). | -| Suspend function overhead | Zero for implementations that don't actually suspend. Kotlin state machine is only created when suspension points exist. | -| PN533FeliCaTagAdapter move breaks Desktop | Desktop uses it from JVM. Moving to commonMain makes it available to all targets including JVM. No breakage. | -| ISO7816CardReader.AppConfig lambda types | Changing `(Protocol) -> T` to `suspend (Protocol) -> T` may need updates where lambdas are constructed. Check `ISO7816Dispatcher.buildAppConfigs()`. | - -## Verification Checklist - -- [ ] `./gradlew allTests` passes -- [ ] `./gradlew :app:web:wasmJsBrowserDistribution` succeeds -- [ ] `./gradlew :app:desktop:compileKotlinJvm` succeeds -- [ ] `./gradlew :app:android:assembleDebug` succeeds (if SDK available) -- [ ] WebCardScanner no longer emits "card reading in development" error -- [ ] WebCardScanner emits `RawCard<*>` through `scannedCards` flow on successful read