Skip to content

Commit f2437eb

Browse files
Claudeclaude
andcommitted
feat(web): implement WebUSB NFC scanning, file picker, and MDST resource loading
Replaces stub implementations with working WebUSB PN533 transport (card detection via poll loop), browser file picker for JSON/binary import, and synchronous XHR-based MDST resource loading for wasmJs. Updates tests to use runTest instead of runBlocking for wasmJs compatibility. Adds web build step to CI and updates CLAUDE.md to reflect web target and FormattedString conventions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 76afa5b commit f2437eb

File tree

17 files changed

+822
-110
lines changed

17 files changed

+822
-110
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ concurrency:
1212

1313
jobs:
1414
build-and-test:
15-
name: Build & Test (JVM + Android)
15+
name: Build & Test (JVM + Android + Web)
1616
runs-on: ubuntu-latest
1717
steps:
1818
- uses: actions/checkout@v4
@@ -38,6 +38,9 @@ jobs:
3838
- name: Build Desktop
3939
run: ./gradlew :app:desktop:assemble
4040

41+
- name: Build Web
42+
run: ./gradlew :app:web:wasmJsBrowserDistribution
43+
4144
ios:
4245
name: Build & Test (iOS)
4346
runs-on: nscloud-macos-sequoia-arm64-6x14

CLAUDE.md

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Project Overview
44

5-
FareBot is a Kotlin Multiplatform (KMP) Android/iOS app for reading NFC transit cards. It is being ported from/aligned with [Metrodroid](https://github.com/metrodroid/metrodroid).
5+
FareBot is a Kotlin Multiplatform (KMP) Android/iOS/Web app for reading NFC transit cards. It is being ported from/aligned with [Metrodroid](https://github.com/metrodroid/metrodroid). The web target uses Kotlin/Wasm (wasmJs) with WebUSB for NFC reader support.
66

77
**Metrodroid source code is in the `metrodroid/` directory in this repo.** Always use this local copy for comparisons and porting — do not fetch from GitHub.
88

@@ -32,10 +32,11 @@ When porting code from Metrodroid: **do a faithful port**. Do not simplify, abbr
3232

3333
- `ImmutableByteArray``ByteArray`
3434
- `Parcelize`/`Parcelable``kotlinx.serialization.Serializable`
35-
- `Localizer.localizeString(R.string.x)``stringResource.getString(Res.string.x)`
35+
- `Localizer.localizeString(R.string.x)``FormattedString(Res.string.x)`
3636
- `Timestamp`/`TimestampFull`/`Daystamp``kotlinx.datetime.Instant`
3737
- `TransitData``TransitInfo`
3838
- `CardTransitFactory``TransitFactory<CardType, TransitInfoType>`
39+
- `String` (user-facing) → `FormattedString` (sealed class in `base/util/`)
3940

4041
Do NOT:
4142
- Skip features "for later"
@@ -57,24 +58,30 @@ Do NOT make speculative changes hoping they fix the issue. Each failed guess was
5758

5859
### 5. All code in commonMain unless it requires OS APIs
5960

60-
Write all code in `src/commonMain/kotlin/`. Only use `androidMain` or `iosMain` for code that directly interfaces with platform APIs (NFC hardware, file system, UI system dialogs). No Objective-C. Tests use `kotlin.test`.
61+
Write all code in `src/commonMain/kotlin/`. Only use `androidMain`, `iosMain`, or `wasmJsMain` for code that directly interfaces with platform APIs (NFC hardware, file system, UI system dialogs, WebUSB). No Objective-C. Tests use `kotlin.test`.
6162

62-
### 6. Use StringResource for all user-facing strings
63+
### 6. Use FormattedString for all user-facing strings
64+
65+
All user-facing strings use the `FormattedString` sealed class, which defers string resolution to the UI layer (avoiding `runBlocking` that blocks the JS event loop on wasmJs).
6366

64-
All user-facing strings must go through Compose Multiplatform resources:
6567
- Define strings in `src/commonMain/composeResources/values/strings.xml`
66-
- For UI labels in `TransitInfo.getInfo()`, use `ListItem(Res.string.xxx, value)` or `HeaderListItem(Res.string.xxx)` directly
67-
- For dynamic string formatting, use `runBlocking { getString(Res.string.xxx) }`
68-
- Legacy pattern: Pass `StringResource` to factories — still works but not required for new code
68+
- Use `FormattedString(Res.string.xxx)` for resource-backed strings
69+
- Use `FormattedString("literal")` for dynamic/computed strings
70+
- Use `FormattedString(Res.string.xxx, arg1, arg2)` for formatted strings
71+
- Use `FormattedString.plural(Res.plurals.xxx, count, args...)` for plurals
72+
- Concatenate with `+` operator: `FormattedString("a") + FormattedString("b")`
73+
74+
The UI resolves strings via `@Composable formattedString.resolve()` or `suspend formattedString.resolveAsync()`.
6975

7076
Example patterns:
7177
```kotlin
72-
// Preferred for static labels
73-
ListItem(Res.string.card_type, cardType)
74-
HeaderListItem(Res.string.card_details)
78+
// In transit modules — return FormattedString, not String
79+
override val cardName: FormattedString get() = FormattedString(Res.string.card_name)
80+
override val warning: FormattedString? get() = FormattedString(Res.string.some_warning, count)
7581

76-
// For dynamic values
77-
val formatted = runBlocking { getString(Res.string.balance_format) }
82+
// For ListItem/HeaderListItem
83+
ListItem(Res.string.card_type, value)
84+
HeaderListItem(Res.string.card_details)
7885
```
7986

8087
Do NOT hardcode English strings in Kotlin files.
@@ -103,32 +110,29 @@ Do NOT claim work is complete without verification.
103110

104111
### 9. Preserve context across sessions
105112

106-
Key project state is in:
107-
- `/Users/eric/.claude/plans/` — implementation plans (check newest first)
108-
- `/Users/eric/Code/farebot/REMAINING-WORK.md` — tracked remaining work
109-
- Session transcripts in `/Users/eric/.claude/projects/-Users-eric-Code-farebot/`
110-
111-
When continuing from a previous session, read these files to recover context rather than starting from scratch.
113+
When continuing from a previous session, check for implementation plans and session transcripts in `~/.claude/` to recover context rather than starting from scratch.
112114

113115
## Build Commands
114116

115117
```bash
116-
./gradlew allTests # Run all tests
117-
./gradlew assemble # Full build (Android + iOS frameworks)
118+
./gradlew allTests # Run all tests
119+
./gradlew assemble # Full build (Android + iOS + Web)
118120
./gradlew :app:android:assembleDebug # Android only
121+
./gradlew :app:web:wasmJsBrowserDistribution # Web (Wasm) only
119122
```
120123

121124
## Module Structure
122125

123126
- `base/` — Core utilities, MDST reader, ByteArray extensions (`:base`)
124127
- `card/` — Shared card abstractions (`:card`)
125-
- `card/*/` — Card type implementations: classic, desfire, felica, ultralight, iso7816, cepas, vicinity (`:card:*`)
128+
- `card/*/` — Card type implementations: classic, desfire, felica, ultralight, iso7816, cepas, china, ksx6924, vicinity (`:card:*`)
126129
- `transit/` — Shared transit abstractions: Trip, Station, TransitInfo, TransitCurrency (`:transit`)
127130
- `transit/*/` — Transit system implementations, one per system (`:transit:*`)
128131
- `transit/serialonly/` — Identification-only systems (serial number + reason, matches Metrodroid's `serialonly/`)
129132
- `app/` — KMP app framework: UI, ViewModels, DI, platform code (`:app`)
130133
- `app/android/` — Android app shell: Activities, manifest, resources (`:app:android`)
131134
- `app/desktop/` — Desktop app shell (`:app:desktop`)
135+
- `app/web/` — Web app shell: Kotlin/Wasm entry point, WebUSB NFC support, localStorage persistence (`:app:web`)
132136
- `app/ios/` — iOS app shell: Swift entry point, assets, config (Xcode project, not a Gradle module)
133137
- `tools/mdst/` — JVM CLI for MDST station databases: lookup, dump, compile (`:tools:mdst`)
134138

@@ -137,9 +141,16 @@ When continuing from a previous session, read these files to recover context rat
137141
1. Create `transit/{name}/build.gradle.kts`
138142
2. Add `include(":transit:{name}")` to `settings.gradle.kts`
139143
3. Add `api(project(":transit:{name}"))` to `app/build.gradle.kts`
140-
4. Register factory in `TransitFactoryRegistry.kt` (Android)
141-
5. Register factory in `MainViewController.kt` (iOS, non-Classic cards only)
142-
6. Add string resources in `composeResources/values/strings.xml`
144+
4. Register factory in `TransitFactoryRegistryBuilder.kt` (shared, used by all platforms)
145+
5. Add string resources in `composeResources/values/strings.xml`
146+
147+
## CI
148+
149+
GitHub Actions (`.github/workflows/ci.yml`). Runs tests and builds on push/PR.
150+
151+
## Development Environment
152+
153+
A devcontainer is available (`.devcontainer/`) with Android SDK, JDK 25, and sandboxed networking. This is the default environment for Claude Code.
143154

144155
## Agent Teams
145156

app/src/commonTest/kotlin/com/codebutler/farebot/test/ClipperTransitTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import com.codebutler.farebot.transit.Trip
3535
import com.codebutler.farebot.transit.clipper.ClipperTransitFactory
3636
import com.codebutler.farebot.transit.clipper.ClipperTransitInfo
3737
import com.codebutler.farebot.transit.clipper.ClipperTrip
38-
import kotlinx.coroutines.runBlocking
38+
import kotlinx.coroutines.test.runTest
3939
import kotlin.math.abs
4040
import kotlin.test.Test
4141
import kotlin.test.assertEquals
@@ -227,7 +227,7 @@ class ClipperTransitTest {
227227

228228
@Test
229229
fun testDemoCard() =
230-
runBlocking {
230+
runTest {
231231
assertEquals(32 * 2, REFILL.length)
232232

233233
// This is mocked-up data, probably has a wrong checksum.

app/src/commonTest/kotlin/com/codebutler/farebot/test/EasyCardTransitTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ package com.codebutler.farebot.test
2828
import com.codebutler.farebot.transit.TransitCurrency
2929
import com.codebutler.farebot.transit.Trip
3030
import com.codebutler.farebot.transit.easycard.EasyCardTransitFactory
31-
import kotlinx.coroutines.runBlocking
31+
import kotlinx.coroutines.test.runTest
3232
import kotlinx.datetime.TimeZone
3333
import kotlinx.datetime.toLocalDateTime
3434
import kotlin.test.Test
@@ -68,7 +68,7 @@ class EasyCardTransitTest : CardDumpTest() {
6868

6969
@Test
7070
fun testDeadbeefEnglish() =
71-
runBlocking {
71+
runTest {
7272
val card = loadMfcCard("easycard/deadbeef.mfc")
7373

7474
// Verify card is detected as EasyCard
@@ -138,7 +138,7 @@ class EasyCardTransitTest : CardDumpTest() {
138138
*/
139139
@Test
140140
fun testDeadbeefChineseTraditional() =
141-
runBlocking {
141+
runTest {
142142
val card = loadMfcCard("easycard/deadbeef.mfc")
143143

144144
assertTrue(factory.check(card), "Card should be detected as EasyCard")

app/src/commonTest/kotlin/com/codebutler/farebot/test/FlipperIntegrationTest.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import com.codebutler.farebot.transit.orca.OrcaTransitFactory
3333
import com.codebutler.farebot.transit.orca.OrcaTransitInfo
3434
import com.codebutler.farebot.transit.suica.SuicaTransitFactory
3535
import com.codebutler.farebot.transit.suica.SuicaTransitInfo
36-
import kotlinx.coroutines.runBlocking
36+
import kotlinx.coroutines.test.runTest
3737
import kotlin.test.Test
3838
import kotlin.test.assertEquals
3939
import kotlin.test.assertNotNull
@@ -58,7 +58,7 @@ class FlipperIntegrationTest {
5858

5959
@Test
6060
fun testOrcaFromFlipper() =
61-
runBlocking {
61+
runTest {
6262
val data = loadFlipperDump("ORCA.nfc")
6363
val rawCard = FlipperNfcParser.parse(data)
6464
assertNotNull(rawCard, "Failed to parse ORCA Flipper dump")
@@ -95,7 +95,7 @@ class FlipperIntegrationTest {
9595

9696
@Test
9797
fun testClipperFromFlipper() =
98-
runBlocking {
98+
runTest {
9999
val data = loadFlipperDump("Clipper.nfc")
100100
val rawCard = FlipperNfcParser.parse(data)
101101
assertNotNull(rawCard, "Failed to parse Clipper Flipper dump")
@@ -272,7 +272,7 @@ class FlipperIntegrationTest {
272272

273273
@Test
274274
fun testSuicaFromFlipper() =
275-
runBlocking {
275+
runTest {
276276
val data = loadFlipperDump("Suica.nfc")
277277
val rawCard = FlipperNfcParser.parse(data)
278278
assertNotNull(rawCard, "Failed to parse Suica Flipper dump")
@@ -511,7 +511,7 @@ class FlipperIntegrationTest {
511511

512512
@Test
513513
fun testPasmoFromFlipper() =
514-
runBlocking {
514+
runTest {
515515
val data = loadFlipperDump("PASMO.nfc")
516516
val rawCard = FlipperNfcParser.parse(data)
517517
assertNotNull(rawCard, "Failed to parse PASMO Flipper dump")
@@ -658,7 +658,7 @@ class FlipperIntegrationTest {
658658

659659
@Test
660660
fun testIcocaFromFlipper() =
661-
runBlocking {
661+
runTest {
662662
val data = loadFlipperDump("ICOCA.nfc")
663663
val rawCard = FlipperNfcParser.parse(data)
664664
assertNotNull(rawCard, "Failed to parse ICOCA Flipper dump")

app/src/commonTest/kotlin/com/codebutler/farebot/test/OrcaTransitTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import com.codebutler.farebot.transit.TransitCurrency
3030
import com.codebutler.farebot.transit.Trip
3131
import com.codebutler.farebot.transit.orca.OrcaTransitFactory
3232
import com.codebutler.farebot.transit.orca.OrcaTransitInfo
33-
import kotlinx.coroutines.runBlocking
33+
import kotlinx.coroutines.test.runTest
3434
import kotlin.math.abs
3535
import kotlin.test.Test
3636
import kotlin.test.assertContains
@@ -84,7 +84,7 @@ class OrcaTransitTest {
8484

8585
@Test
8686
fun testDemoCard() =
87-
runBlocking {
87+
runTest {
8888
val card = constructOrcaCard()
8989

9090
// Test TransitIdentity

app/src/commonTest/kotlin/com/codebutler/farebot/test/SampleTransitDataDumpTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import com.codebutler.farebot.shared.transit.TransitFactoryRegistry
3131
import com.codebutler.farebot.shared.transit.createTransitFactoryRegistry
3232
import com.codebutler.farebot.transit.Subscription
3333
import com.codebutler.farebot.transit.Trip
34-
import kotlinx.coroutines.runBlocking
34+
import kotlinx.coroutines.test.runTest
3535
import kotlinx.serialization.json.Json
3636
import kotlin.test.Test
3737

@@ -83,7 +83,7 @@ class SampleTransitDataDumpTest {
8383

8484
@Test
8585
fun dumpAllSampleTransitData() =
86-
runBlocking {
86+
runTest {
8787
val output =
8888
buildString {
8989
appendLine("# FareBot Sample Transit Data Dump")
@@ -167,7 +167,7 @@ class SampleTransitDataDumpTest {
167167
if (balances != null && balances.isNotEmpty()) {
168168
appendLine(" balances:")
169169
for (bal in balances) {
170-
appendLine(" - name: ${yamlValue(bal.name)}")
170+
appendLine(" - name: ${yamlValue(bal.name?.resolveAsync())}")
171171
appendLine(" amount: \"${bal.balance.formatCurrencyString(isBalance = true)}\"")
172172
}
173173
} else {

0 commit comments

Comments
 (0)