Skip to content

Commit 82dacd2

Browse files
codebutlerClaudeclaude
authored
feat: add web (wasmJs) target with FormattedString refactor (#226)
* refactor: replace StringResource/runBlocking with FormattedString sealed class Introduce FormattedString sealed class (Literal, Resource, Plural, Concat) to defer string resolution to the UI layer, eliminating all runBlocking and getStringBlocking usage that blocked the JS event loop on wasmJs. Key changes: - All user-facing string fields (cardName, agencyName, routeName, stationName, subscriptionName, warning, emptyStateMessage, etc.) now return FormattedString instead of String - Remove StringResource interface, DefaultStringResource, TestStringResource, and all getStringBlocking platform actuals - Remove ObfuscatedTrip and TripObfuscator (unused) - Update FareBotUiTree/ListItem/HeaderListItem to use FormattedString - Update all ~100 transit modules to use FormattedString(Res.string.xxx) - For @serializable types (Station, TransitBalance), use @transient formattedName/formattedStationName fields alongside serializable String fields - Update App.kt, HelpScreen, TripMapScreen, CardViewModel, HistoryViewModel - Update all test files to use assertFormattedEquals() helper and assertResourceEquals() for resource key comparison - wasmJs and JVM targets compile and tests pass Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add wasmJs target to all KMP modules Add wasmJs { browser() } target to the root build.gradle.kts so all KMP subprojects automatically get a WebAssembly compilation target. Guard the compose.desktop.currentOs injection to only apply when the jvm target exists. Add sqldelight-web-worker-driver to the version catalog for future use. Convert getStringBlocking/getPluralStringBlocking to expect/actual since runBlocking is unavailable on wasmJs. Provide wasmJs actual implementations for all expect declarations in the base module: ResourceAccessor, BundledDatabaseDriverFactory, SystemLocale, and GetStringBlocking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(web): add wasmJs platform actuals for app module Provide wasmJs actual implementations for the three expect declarations in the app module: - DeviceRegion: reads navigator.language to extract country code - CardsMapScreen: no-op (platformHasCardsMap = false), matching JVM - TripMapScreen: no-op composable, matching JVM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(web): add web app shell with entry point, DI graph, and localStorage persistence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract PN533 protocol to commonMain, add WebUSB transport stub Move pure-logic PN533 files (PN533.kt, PN533CardInfo.kt, PN533CardTransceiver.kt, PN533CommunicateThruTransceiver.kt, PN533ClassicTechnology.kt, PN533UltralightTechnology.kt) from jvmMain to commonMain so they can be used on all Kotlin targets. Create a PN533Transport interface in commonMain with PN533Exception and PN533CommandException. Rename the usb4java implementation to Usb4JavaPN533Transport (stays in jvmMain). Add a WebUsbPN533Transport stub in wasmJsMain as a placeholder for future WebUSB JS interop. Replace String.format() calls with multiplatform-compatible hex formatting. Remove Thread.sleep(10) from PN533.resetMode() (not available in commonMain, negligible delay). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix up web * 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> --------- Co-authored-by: Claude <claude@codebutler.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 77378ca commit 82dacd2

File tree

368 files changed

+5764
-5028
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

368 files changed

+5764
-5028
lines changed

.devcontainer/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
1010
bash python3 locales make \
1111
iptables ipset iproute2 dnsutils aggregate jq nano vim tmux \
1212
build-essential libncurses-dev libz-dev libc6-dev \
13+
bc coreutils findutils diffutils patch gawk sed grep file tree \
14+
xz-utils bzip2 gzip tar zip rsync socat netcat-openbsd openssh-client \
15+
htop lsof strace \
1316
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
1417
&& ln -sf /bin/bash /bin/sh \
1518
&& sed -i '/en_US.UTF-8/s/^# //' /etc/locale.gen && locale-gen

.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

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ gradle/gradle-daemon-jvm.properties
3737

3838
# Git worktrees
3939
.worktrees/
40+
worktrees/
4041

4142
# Generated review pages
4243
scripts/card-image-review.html

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

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
IOS_DEVICE_ID := $(shell xcrun xctrace list devices 2>/dev/null | grep -v Simulator | grep -E '\([0-9A-F-]+\)$$' | grep -v Mac | head -1 | grep -oE '[0-9A-F]{8}-[0-9A-F]{16}')
22
IOS_APP_PATH = $(shell ls -d ~/Library/Developer/Xcode/DerivedData/FareBot-*/Build/Products/Debug-iphoneos/FareBot.app 2>/dev/null | head -1)
33

4-
.PHONY: android android-install ios ios-sim ios-install desktop test clean help
4+
.PHONY: android android-install ios ios-sim ios-install desktop web web-run test clean help
55

66
## Android
77

@@ -31,6 +31,14 @@ ios-install: ios ## Build and install on connected iOS device
3131
desktop: ## Run macOS desktop app (experimental)
3232
./gradlew :app:desktop:run
3333

34+
## Web
35+
36+
web: ## Build web app (experimental, WebAssembly)
37+
./gradlew :app:web:wasmJsBrowserDistribution
38+
39+
web-run: ## Run web app dev server with hot reload
40+
./gradlew :app:web:wasmJsBrowserDevelopmentRun
41+
3442
## Tests
3543

3644
test: ## Run all tests

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Read your remaining balance, recent trips, and other information from contactless public transit cards using your NFC-enabled Android or iOS device.
44

5-
FareBot is a [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) app built with [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/), targeting Android (NFC), iOS (CoreNFC), and macOS (experimental, via PC/SC smart card readers or PN533 raw USB NFC controllers).
5+
FareBot is a [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) app built with [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/), targeting Android (NFC), iOS (CoreNFC), macOS (experimental, via PC/SC smart card readers or PN533 raw USB NFC controllers), and Web (experimental, via WebAssembly).
66

77
## Platform Compatibility
88

@@ -184,6 +184,7 @@ Some MIFARE Classic cards require encryption keys to read. You can obtain keys u
184184
* **Android:** NFC-enabled device running Android 6.0 (API 23) or later
185185
* **iOS:** iPhone 7 or later with iOS support for CoreNFC
186186
* **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
187+
* **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.
187188

188189
## Building
189190

@@ -201,6 +202,8 @@ $ make # show all targets
201202
| `make ios-sim` | Build iOS app for simulator |
202203
| `make ios-install` | Build and install on connected iOS device (auto-detects device) |
203204
| `make desktop` | Run macOS desktop app (experimental) |
205+
| `make web` | Build web app (experimental, WebAssembly) |
206+
| `make web-run` | Run web app dev server with hot reload |
204207
| `make test` | Run all tests |
205208
| `make clean` | Clean all build artifacts |
206209

@@ -257,6 +260,7 @@ Compatible with [Zed](https://zed.dev/docs/dev-containers), VS Code (Remote - Co
257260
- `app/android/` — Android app shell (Activities, manifest, resources)
258261
- `app/ios/` — iOS app shell (Swift entry point, assets, config)
259262
- `app/desktop/` — macOS desktop app (experimental, PC/SC + PN533 + RC-S956 USB NFC)
263+
- `app/web/` — Web app (experimental, WebAssembly via Kotlin/Wasm)
260264

261265
## Written By
262266

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ kotlin {
4747
}
4848
commonTest.dependencies {
4949
implementation(kotlin("test"))
50+
implementation(libs.kotlinx.coroutines.test)
5051
}
5152
commonMain.dependencies {
5253
implementation(libs.compose.runtime)

app/desktop/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ plugins {
66
}
77

88
kotlin {
9+
jvmToolchain(25)
10+
911
sourceSets {
1012
jvmMain.dependencies {
1113
implementation(project(":app"))

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package com.codebutler.farebot.desktop
22

33
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
4-
import com.codebutler.farebot.base.util.DefaultStringResource
5-
import com.codebutler.farebot.base.util.StringResource
64
import com.codebutler.farebot.card.serialize.CardSerializer
75
import com.codebutler.farebot.persist.CardKeysPersister
86
import com.codebutler.farebot.persist.CardPersister
@@ -70,10 +68,6 @@ abstract class DesktopAppGraph : AppGraph {
7068
@SingleIn(AppScope::class)
7169
fun provideTransitFactoryRegistry(): TransitFactoryRegistry = createTransitFactoryRegistry()
7270

73-
@Provides
74-
@SingleIn(AppScope::class)
75-
fun provideStringResource(): StringResource = DefaultStringResource()
76-
7771
@Provides
7872
@SingleIn(AppScope::class)
7973
fun provideCardScanner(): CardScanner = DesktopCardScanner()

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@
2323
package com.codebutler.farebot.desktop
2424

2525
import com.codebutler.farebot.card.nfc.pn533.PN533
26-
import com.codebutler.farebot.card.nfc.pn533.PN533Transport
26+
import com.codebutler.farebot.card.nfc.pn533.Usb4JavaPN533Transport
2727

2828
/**
2929
* NXP PN533 reader backend (e.g., SCM SCL3711).
3030
*/
3131
class PN533ReaderBackend(
32-
transport: PN533Transport? = null,
32+
transport: Usb4JavaPN533Transport? = null,
3333
) : PN53xReaderBackend(transport) {
3434
override val name: String = "PN533"
3535

0 commit comments

Comments
 (0)