Skip to content

feat(web): full NFC card reading over WebUSB#231

Merged
codebutler merged 26 commits intomasterfrom
feat/webusb-full-card-reading
Feb 16, 2026
Merged

feat(web): full NFC card reading over WebUSB#231
codebutler merged 26 commits intomasterfrom
feat/webusb-full-card-reading

Conversation

@codebutler
Copy link
Owner

Summary

  • Make all NFC I/O interfaces (CardTransceiver, ClassicTechnology, UltralightTechnology, FeliCaTagAdapter, VicinityTechnology) suspend-compatible, bridging the sync/async mismatch between platform NFC APIs and WebUSB's Promise-based API
  • Implement complete card reading pipeline in WebCardScanner supporting DESFire, MIFARE Classic, MIFARE Ultralight, FeliCa, CEPAS, and ISO 7816 cards — using the same readers as desktop/Android/iOS
  • Make PN533Transport and all PN533 protocol methods suspend, enabling WebUSB's async bulk transfers to work seamlessly through Kotlin coroutines
  • Update all platform call sites (Desktop, Android, iOS) to accommodate suspend interfaces, using runBlocking at platform boundaries where needed
  • Update all card module tests with runTest wrappers and suspend mock overrides
  • Allow nodejs.org in devcontainer firewall for Kotlin/Wasm test runner

Test plan

  • All card module JVM tests pass (./gradlew :card:jvmTest :card:desfire:jvmTest :card:classic:jvmTest :card:ultralight:jvmTest :card:felica:jvmTest :card:iso7816:jvmTest :card:vicinity:jvmTest)
  • Desktop module compiles (./gradlew :app:desktop:compileKotlinJvm)
  • ktlintFormat passes cleanly
  • Manual test: Connect PN533/SCL3711 USB reader, open web app in Chrome, scan a transit card — should read full card data (not just detection)

🤖 Generated with Claude Code

Claude and others added 26 commits February 16, 2026 11:15
…led 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>
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>
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>
…rage persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…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>
…rce 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>
Adds suspend to PN533Transport.sendCommand() and sendAck(), and
propagates through all PN533 controller methods. WebUsbPN533Transport
now delegates sendCommand() to sendCommandAsync() instead of throwing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CardTransceiver.transceive(), ClassicTechnology.readBlock()/auth,
UltralightTechnology.readPages()/transceive(), FeliCaTagAdapter I/O
methods, and VicinityTechnology.transceive() are now suspend functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mechanical change: adds suspend keyword to all implementations of
CardTransceiver, ClassicTechnology, UltralightTechnology,
FeliCaTagAdapter, and VicinityTechnology across PN533, PCSC,
Android, and iOS source sets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
No JVM-specific dependencies — now available for all platforms
including wasmJs/web. Methods updated to suspend per FeliCaTagAdapter
interface changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DesfireProtocol, ISO7816Protocol, CEPASProtocol, and UltralightProtocol
now use suspend functions for NFC I/O operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add suspend to all card reader entry points and internal methods:
- DesfireCardReader: readCard, readApplications, readFiles, readFile, etc.
- ClassicCardReader: readCard
- UltralightCardReader: readCard, detectCardType
- FeliCaReader: readTag
- CEPASCardReader: readCard
- ISO7816CardReader: readCard, tryReadApplication, readSfiFile, etc.
- VicinityCardReader: readCard
- ISO7816Dispatcher: readCard, tryISO7816

Also update AppConfig lambda types to suspend function types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Desktop: wrap card reading in runBlocking on poll thread, replace
Thread.sleep with coroutine delay.
Android: propagate suspend through TagReader base and all implementations.
iOS: use runBlocking on GCD worker queue for suspend card readers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add kotlinx-coroutines-test dependency to card sub-modules.
Update mock/fake implementations to use suspend overrides.
Wrap test methods calling suspend functions in runTest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.
Removed duplicate inListPassiveTarget/parseTarget helper methods
in favor of the standard PN533 class methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
sendAck() and getFirmwareVersion() are now suspend functions after the
NFC I/O suspend refactoring, so discoverBackends() needs to be suspend
too. It's already called from within a coroutine.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The Kotlin/Wasm (wasmJs) test task downloads Node.js from nodejs.org
to execute tests. Add it to the firewall allowlist so `./gradlew allTests`
works in the devcontainer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
All conflicts resolved by keeping our branch's suspend-compatible
versions of interfaces, implementations, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The Kotlin/Wasm IR linker hangs when compiling test executables for all
library modules. These modules get sufficient test coverage from jvmTest.
Only app:web needs wasmJs test compilation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…vmTest

The Kotlin/Wasm IR linker hangs when linking executables for library
modules. Disable all wasmJs executable and test tasks for non-web
modules — they only need klib compilation for app:web to consume, and
get test coverage from jvmTest.

Also switch CI from allTests (which includes iOS/wasmJs targets not
available on ubuntu) to jvmTest. iOS tests run in the separate ios job.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Lint runs first as a gate, then test/android/desktop/web/ios all run
in parallel. Failures are isolated and the overall pipeline is faster.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…firewall

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The production wasmJsBrowserDistribution triggers expensive
compileProductionExecutableKotlinWasmJsOptimize. The development
distribution verifies compilation and linking without the slow
optimization step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@codebutler codebutler merged commit ef85696 into master Feb 16, 2026
6 checks passed
@codebutler codebutler deleted the feat/webusb-full-card-reading branch February 16, 2026 23:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants