Implements the verifiable pure-Kotlin core of the Android client per
docs/ANDROID_CLIENT_PLAN.md, mirroring the iOS SPM package set as Gradle modules.
Built + reviewed via multi-agent workflow (explore→implement→verify→review),
then review findings fixed with regression tests.
Modules (all pure JVM, kotlin("jvm"); Android-framework modules scaffolded but
gated off in settings — no Android SDK in this env):
- :wire-protocol — frozen wire contract (sealed Client/ServerMessage, enums,
HostEndpoint CSWSH origin derivation, transport interfaces), hand-rolled codec
byte-identical to the server's JSON.stringify + tolerant kotlinx decode.
- :session-core — ReconnectMachine (1→2→4→8→16→30 backoff), PingScheduler,
GateTracker (two-line epoch guard), AwayDigest, UnreadLedger, TitleSanitizer,
KeyByteMap (byte-matches public/keybar.ts).
- :api-client — all REST routes, tolerant decode, Origin-iff-guarded, prefs
unknown-key preservation, pairing probe + host-tier classifier.
- :client-tls — pure half: PKCS#12 parse, X509KeyManager alias logic,
CertificateSummary, provider-agnostic wrong-passphrase classification.
- :test-support — FakeTransport / FakeHttpTransport / virtual-clock fakes.
Verify: ./gradlew clean test → 218 tests, 0 failures (wire-protocol 47,
session-core 60, api-client 69, client-tls 27, test-support 15).
Review (3 lenses, 8/10 each) findings fixed: sessionId JSON-injection escape,
CancellationException propagation in PingScheduler, PairingProbe close-on-cancel
leak, HostEndpoint trim, HostClassifier hoisted to :wire-protocol (type unified),
BouncyCastle-portable wrong-passphrase detection, lone-surrogate escaping.
Known gap (documented, deferred): /push/fcm-token has no server route yet —
plan task A33 (src/push/fcm.ts) delivers it.
WebTerm — Android client
A native Android client for the WebTerm browser-terminal server, targeting functional
parity with the shipped iOS client. See the full design in
docs/ANDROID_CLIENT_PLAN.md (stack §2, module
architecture §3, server contract §4, task waves §5).
This directory is a Gradle multi-module project. The module set mirrors the iOS SPM package set and inherits its rule: dependencies only flow down; nothing points upward (ARCHITECTURE §1).
⚠️ No-SDK constraint (why only 5 modules build here)
The current build environment has no Android SDK. Everything that can be pure
Kotlin/JVM (kotlin("jvm")) is built and unit-tested now; anything that needs the
Android framework (com.android.* plugins) is scaffolded but disabled.
- Enabled now (pure Kotlin/JVM,
./gradlew test-able)::wire-protocol,:session-core,:api-client,:client-tls,:test-support. - Scaffolded but COMMENTED OUT in
settings.gradle.kts(dirs + abuild.gradle.ktsstub exist, marked// TODO(android-sdk))::app,:terminal-view,:host-registry,:client-tls-android.
To bring the Android modules online later: install an SDK, add
local.properties → sdk.dir, add the Android Gradle Plugin + google() to
pluginManagement, then uncomment the include(...) lines and the plugin blocks in
each stub.
Module map (mirror of the iOS SPM packages — plan §3)
| iOS SPM package | Android module | Kind | Status |
|---|---|---|---|
| WireProtocol | :wire-protocol |
pure Kotlin/JVM | ✅ built |
| SessionCore (reducers) | :session-core |
pure Kotlin/JVM | ✅ built |
| APIClient | :api-client |
pure Kotlin/JVM | ✅ built |
| ClientTLS (pure half) | :client-tls |
pure Kotlin/JVM | ✅ built |
| TestSupport | :test-support |
pure Kotlin/JVM (fakes) | ✅ built |
| ClientTLS (fwk half) | :client-tls-android |
Android (AndroidKeyStore/Tink) | ⏸ SDK-gated |
| HostRegistry | :host-registry |
Android (DataStore) | ⏸ SDK-gated |
| SwiftTerm host view | :terminal-view |
Android (Termux wrap) | ⏸ SDK-gated |
| App/WebTerm | :app |
Android app (Compose/Hilt/FCM) | ⏸ SDK-gated |
Not yet scaffolded:
:transport-okhttp(OkHttpTermTransport/HttpTransportimpls, JVM) is owned by task A7 and will be added then. The iOSURLSession*Transports consolidate into it (plan §3 framing note).
Dependency graph (arrows = "depends on")
:app (SDK-gated)
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls-android
(SDK-gated) │ │ (SDK-gated) │
│ │ │ ▼
│ │ │ :client-tls (pure)
└──────┬───────┴──────────┴──────────────┬────────────────┘
▼ ▼
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
▲
└──────── :test-support → test source sets only
:wire-protocol is the frozen shared contract (Android analogue of
src/types.ts + WireProtocol) — ClientMessage/ServerMessage, MessageCodec,
Validation, WireConstants, HostEndpoint (the single Origin/wsURL derivation),
and the TermTransport / HttpTransport / PingableTermTransport boundary
interfaces. New wire types are added only here (a coordination point).
Toolchain
- Gradle 9.6.1 (via the committed wrapper — always use
./gradlew). - Kotlin 2.3.21 (matches the Kotlin embedded in Gradle 9.6.1).
- JVM toolchain 17 (
jvmToolchain(17)in every module). - Versions are pinned in the version catalog
gradle/libs.versions.toml: kotlinx-serialization-json, kotlinx-coroutines-core/-test, JUnit5 (Jupiter), Turbine, MockK.
Pure modules apply kotlin("jvm") + kotlin("plugin.serialization"), wire the
libs.bundles.unit-test bundle into testImplementation, and run tests on the
JUnit Platform (tasks.test { useJUnitPlatform() }).
Build & test
# Use the committed wrapper for everything.
./gradlew help # sanity: the build configures
./gradlew projects # lists the 5 pure modules
./gradlew build # compile all pure modules
./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
Testing target: ≥80% Kover coverage on the pure modules (
:wire-protocol,:session-core,:api-client,:client-tlspure half). TDD, immutable data, small focused files — same discipline as the rest of the repo.