Files
web-terminal/android/README.md
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

188 lines
11 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 |
| `URLSession*Transport` | `:transport-okhttp` | pure Kotlin/JVM (OkHttp) | ✅ built |
> `:transport-okhttp` (A7) holds the OkHttp `TermTransport`/`HttpTransport`
> implementations that the two iOS `URLSession*Transport`s consolidate into
> (plan §3 framing note). OkHttp is a plain JVM library, so the module builds and
> unit-tests (MockWebServer) with **no** Android SDK — including the
> access-token cookie on the WS upgrade (`OkHttpAccessTokenTest`).
### 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 — the four that apply
> the Kover plugin). TDD, immutable data, small focused files — same discipline as
> the rest of the repo.
### Status & what is NOT verified here
- **Green gate**: `./gradlew test :app:assembleDebug koverVerify` — JVM unit
tests, a debug APK, and the 80 % coverage floor on the four gated modules.
- **Emulator**: the app has been built, installed and launched on an
android-35 arm64 emulator (pairing screen rendered, no crash). See the
`PROGRESS_LOG.md` entry for that run.
- **DEFERRED — real device**: hardware-backed keys (StrongBox falls back to
software keys on an emulator), FCM push delivery and the lock-screen
Allow/Deny walkthrough, and general hand-on-glass QA.
- **DEFERRED — instrumented tests**: everything under `src/androidTest/`
(`TinkAccessTokenStoreTest`, DataStore stores, AndroidKeyStore importer,
hardware-key checks) needs a connected device/emulator and is therefore **not**
part of the `./gradlew test` gate; run them with `connectedAndroidTest` on a
booted emulator.
- **DEFERRED — end-to-end access token**: the `WEBTERM_TOKEN` path is covered by
unit tests (`OkHttpAccessTokenTest`, the `:api-client` route tests, the
`AuthCookie` derivation tests), but no automated leg here starts a real server
with `WEBTERM_TOKEN` set and completes a cookie-gated WS upgrade.
## 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"`.