Files
web-terminal/android/README.md
Yaojia Wang 9114630c3a feat(android): access-token support — same frozen contract as iOS
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.
2026-07-30 12:45:27 +02:00

164 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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=<16512 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"`.