Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written Cookie header on every request and on the WS upgrade (no CookieJar, matching the frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401 upgrade as a terminal state with no reconnect loop.
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).
Build environment (SDK installed — all modules build)
The Android SDK is installed and every module — pure Kotlin/JVM and Android-framework alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build against SDK 35/36.
- Pure Kotlin/JVM (
./gradlew test)::wire-protocol,:session-core,:api-client,:client-tls,:test-support,:transport-okhttp. - Android-framework (online in
settings.gradle.kts)::app,:terminal-view,:host-registry,:client-tls-android.
Setup: local.properties → sdk.dir=/usr/local/share/android-commandlinetools;
google() is in pluginManagement/dependencyResolutionManagement. Green gate:
./gradlew test :app:assembleDebug koverVerify.
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) | ✅ built |
| HostRegistry | :host-registry |
Android (DataStore) | ✅ built |
| SwiftTerm host view | :terminal-view |
Android (Termux wrap) | ✅ built |
| App/WebTerm | :app |
Android app (Compose/Hilt/FCM) | ✅ built |
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
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls-android
│ │ │ │
│ │ │ ▼
│ │ │ :client-tls (pure)
└──────┬───────┴──────────┴──────────────┬────────────────┘
▼ ▼
:wire-protocol ◀──────────── :transport-okhttp
▲
└──────── :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),
AuthCookie/AccessTokenRule/AccessTokenSource (the single access-token-cookie
derivation), and the TermTransport / HttpTransport / PingableTermTransport
boundary interfaces. New wire types are added only here (a coordination point).
Access token (WEBTERM_TOKEN) — how the Android client authenticates
A server started with WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]> gates every
HTTP route and the WebSocket upgrade behind a webterm_auth cookie. The Android
client therefore:
- hand-writes
Cookie: webterm_auth=<t>itself — no OkHttpCookieJar, noSet-Cookieparsing. One derivation point (AuthCookie), stamped in exactly three places::api-client'sApiRoute.toHttpRequest(all 12+ routes, RO GETs included), the pairing probe's two hand-built legs, and:transport-okhttp's WS upgrade. The hand-builtGET /projects/difffetcher in:appstamps it too. - keeps the header orthogonal to
Origin: the token never replaces the CSWSH Origin check (the server tests Origin first, then the cookie), and read-only routes still carry no Origin. - validates a typed token ONCE at pairing time with
POST /auth({"token":"…"},Accept: application/json— anAcceptcontainingtext/htmlmakes the server answer 302 instead of 204/401). Four outcomes: 204+Set-Cookie= correct → store it; 204 withoutSet-Cookie= that host has no auth → store nothing; 401 = wrong token; 429 = rate-limited (10/min/IP). - stores it per host (keyed by the canonical origin) in
TinkAccessTokenStore: Tink AEAD under an AndroidKeystore-wrapped, non-exportable master key, in an app-privateSharedPreferencesfile, withandroid:allowBackup="false"+data_extraction_rules.xmlexcluding cloud backup and device transfer. The token is never logged, never in a URL, never in app UI state. - treats a 401 on the WS upgrade as terminal (
FailureReason.UNAUTHORIZED): no reconnect back-off loop, and the banner tells the user to re-enter the token. A 401 on any REST route is the typedApiClientError.Unauthorized.
A host with no WEBTERM_TOKEN is byte-identical to before the feature: no token stored ⇒
no Cookie header at all (LAN zero-config preserved).
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.
Android SDK setup (proven working)
The pure JVM modules need only a JDK + Gradle. The Android-framework modules
(:app, :terminal-view, :host-registry, :client-tls-android — plan AW2+)
need the Android SDK. This machine is set up and the toolchain is proven (an
AGP library module compiled against SDK 35 and produced an AAR):
- SDK location:
/usr/local/share/android-commandlinetools(installed viabrew install --cask android-commandlinetools). - Installed packages:
platform-tools,platforms;android-35,platforms;android-36,build-tools;35.0.0,build-tools;36.0.0. (:appcompiles against SDK 36 — the Kotlin-2.3.21-contemporaneous androidx/Compose line refuses SDK 35;platforms;android-37is not fetchable here as the cmdline-tools are too old to parse the v4 repo XML.) android/local.properties(gitignored) points Gradle at it:sdk.dir=/usr/local/share/android-commandlinetools.- Shell env (for
sdkmanager/adb):export ANDROID_HOME=/usr/local/share/android-commandlinetools.
Wiring an Android module (the working recipe)
- Repos:
google()is in bothpluginManagementanddependencyResolutionManagementinsettings.gradle.kts(needed to resolve AGP + androidx). - Plugin: AGP 9.2.1 (
libs.plugins.android.library/.android.application), compatible with Gradle 9.6.1. - Gotcha: AGP 9 has built-in Kotlin — apply ONLY the android plugin. Adding
org.jetbrains.kotlin.androiderrors with "no longer required since AGP 9.0". - Module block:
android { namespace = "…"; compileSdk = 36; defaultConfig { minSdk = 29 } }. (Framework modules targetcompileSdk = 36;targetSdkstays35per plan §2.) :appUI-stack version matrix (A13, proven:app:assembleDebuggreen): AGP 9.2.1 · Kotlin 2.3.21 · Compose-compiler pluginorg.jetbrains.kotlin.plugin.compose= 2.3.21 · Compose BOM2025.11.01(→ material3 1.4.0, ui/foundation 1.9.5, material3.adaptive 1.2.0, material3-adaptive-navigation-suite 1.4.0) · Hilt (dagger) 2.60.1 via KSP2.3.9· androidx core-ktx 1.17.0 / activity-compose 1.12.4 / lifecycle 2.10.0. Apply plugins:android.application+kotlin.plugin.compose+ksp+dagger.hilt.android(NEVERkotlin.android). Bump these together withcompileSdk 37once platform 37 is installable.
To add more SDK pieces later (e.g. an emulator image for instrumented tests):
sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator".