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