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.
164 lines
9.7 KiB
Markdown
164 lines
9.7 KiB
Markdown
# 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`](../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`](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` (OkHttp `TermTransport`/`HttpTransport`
|
||
> impls, JVM) is owned by task **A7** and will be added then. The iOS
|
||
> `URLSession*Transport`s 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 OkHttp `CookieJar`, no
|
||
`Set-Cookie` parsing. One derivation point (`AuthCookie`), stamped in exactly three
|
||
places: `:api-client`'s `ApiRoute.toHttpRequest` (all 12+ routes, RO GETs included),
|
||
the pairing probe's two hand-built legs, and `:transport-okhttp`'s WS upgrade. The
|
||
hand-built `GET /projects/diff` fetcher in `:app` stamps 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` — an `Accept` containing `text/html`
|
||
makes the server answer 302 instead of 204/401). Four outcomes: 204+`Set-Cookie` =
|
||
correct → store it; 204 **without** `Set-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-private `SharedPreferences` file, with `android:allowBackup="false"` +
|
||
`data_extraction_rules.xml` excluding 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 typed `ApiClientError.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`](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
|
||
|
||
```bash
|
||
# 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-tls` pure 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 via `brew install --cask android-commandlinetools`).
|
||
- **Installed packages:** `platform-tools`, `platforms;android-35`, `platforms;android-36`,
|
||
`build-tools;35.0.0`, `build-tools;36.0.0`. (`:app` compiles against SDK **36** — the
|
||
Kotlin-2.3.21-contemporaneous androidx/Compose line refuses SDK 35; `platforms;android-37`
|
||
is 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 both `pluginManagement` and `dependencyResolutionManagement`
|
||
in `settings.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.android` errors with "no longer required since AGP 9.0".
|
||
- Module block: `android { namespace = "…"; compileSdk = 36; defaultConfig { minSdk = 29 } }`.
|
||
(Framework modules target `compileSdk = 36`; `targetSdk` stays `35` per plan §2.)
|
||
- **`:app` UI-stack version matrix** (A13, proven `:app:assembleDebug` green): AGP 9.2.1 ·
|
||
Kotlin 2.3.21 · Compose-compiler plugin `org.jetbrains.kotlin.plugin.compose` = 2.3.21 ·
|
||
Compose BOM `2025.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 KSP `2.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` (NEVER
|
||
`kotlin.android`). Bump these together with `compileSdk 37` once 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"`.
|