# 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). > **State, honestly:** the app builds, minifies, signs (given a keystore) and cold-starts > to the pairing screen on an emulator. It has **never talked to a real WebTerm host from > a device**, and almost nothing in > [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md) is ticked. Version is > `0.1.0-alpha01` for exactly that reason. Read that checklist before calling anything > here done. ## 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`. - **Instrumented-only:** `:macrobenchmark` (`com.android.test`; assembles here, runs only on a device/emulator). Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`; `google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate: `./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest 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 | | — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources | > `: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`) and the > public-host cleartext refusal (`CleartextGuardTest`). `:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a `com.android.test` module — a **separate APK** that drives `:app` out of process via UiAutomator, which is the only way startup/frame timings are real. It therefore cannot violate "dependencies only flow down": nothing depends on it, and it reaches `:app` through `targetProjectPath`, not a `project()` dependency. It instruments `:app`'s `benchmark` variant (see "Build types" below). The benchmark sources themselves are not written yet. ### 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** for a token it already holds — 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. - **also installs one `AuthCookieJar`** on the single shared `OkHttpClient`, which captures the `Set-Cookie` a live pairing (`POST /auth`) hands back and replays it on REST *and* on the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls). The two are orthogonal: the hand-written header covers a token restored from the Keystore store, the jar covers a cookie the server just issued. A host with neither sends no cookie at all. - 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 every module ./gradlew test # JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK) ./gradlew :app:assembleDebug # the installable debug APK ./gradlew koverVerify # the ≥80% gate on the pure modules # Release path (needs a keystore — see "Release signing") ./gradlew :app:assembleRelease # R8 + resource shrinking + signing ./gradlew :app:lintVitalRelease # the release-blocking lint subset; must be clean # Minified build WITHOUT a keystore — the practical way to test R8 keep rules ./gradlew :app:assembleBenchmark # same shrinking as release, debug-signed → installable ``` > Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`, > `:session-core`, `:api-client`, `:client-tls` pure half — the four modules that > apply the Kover plugin, and therefore the only ones `koverVerify` gates). TDD, > immutable data, small focused files — same discipline as the rest of the repo. > > **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug > (`FirDeclaration was not found for class KtProperty, fir is null`, on > `ThumbnailPipeline.kt`). `lintVitalRelease` passes normally and from a cold lint state. ## Build types | Type | Minified | Shrunk res | Debuggable | Signed with | Purpose | |---|---|---|---|---|---| | `debug` | no | no | yes | debug key | development; stable applicationId for deep-link tests (A32) | | `release` | **yes** | **yes** | no | **release key (required)** | the shipping artifact | | `benchmark` | **yes** | **yes** | no | debug key | `initWith(release)` + `isProfileable`; what `:macrobenchmark` measures, and the only way to exercise R8 without a keystore | `benchmark` exists because a benchmark must measure the code that actually ships. It sets `isProfileable = true`, which makes AGP inject `` into the merged manifest — done there rather than in `AndroidManifest.xml` so the shipping manifest carries no benchmark-only tag. ## Versioning Current: `versionCode = 1`, `versionName = "0.1.0-alpha01"`. - **`versionCode`** is a plain monotonic counter — **+1 for every artifact handed to anyone** (a Play track, an APK sent to a tester, an archived benchmark build). It is deliberately NOT derived from `versionName`; keeping them independent is what lets a hotfix ship without renumbering. It is still `1` because no artifact has ever left the build machine; the first distributed build takes `2`. - **`versionName`** is `-alphaNN`. The client tracks the server's `0.1.x` line (root `package.json` is `0.1.0`). The `-alpha01` suffix is a factual claim about device verification, not marketing: - `-alphaNN` — builds, minifies, JVM-tested; **device QA essentially unstarted**. ← today - `-betaNN` — the A34/A35 blocks of `DEVICE_QA_CHECKLIST.md` pass on real hardware. - `0.1.0` — the whole checklist is ticked. Bump the suffix on any user-visible change while still in alpha; move to `0.1.1-alphaNN` only when the server line moves. ## Release signing There is **no keystore in this repository and there must never be one.** Credentials are read at configuration time from the first of these that has them: 1. `android/keystore.properties` — the conventional path. Copy [`keystore.properties.example`](keystore.properties.example). ⚠️ **Confirm `keystore.properties` is listed in `android/.gitignore` before creating it.** The existing rules cover `*.jks` / `*.keystore` / `*.p12` but not this filename. 2. `android/local.properties` — already gitignored, so it needs no new rule. Same four keys. 3. `WEBTERM_RELEASE_STORE_FILE` / `_STORE_PASSWORD` / `_KEY_ALIAS` / `_KEY_PASSWORD` — for CI. Keys: `webterm.release.storeFile` (absolute, or relative to `android/`), `.storePassword`, `.keyAlias`, `.keyPassword`. **Degradation contract:** with no credentials, `debug`, `benchmark`, `assembleDebugAndroidTest` and every test still work, and `:app:assembleRelease` **fails at `packageRelease`** with instructions. It fails at *packaging*, deliberately after R8, so an unconfigured machine still gets a full, verifiable R8 run. It never silently emits `app-release-unsigned.apk` (which is exactly what it used to do). **Archive `app/build/outputs/mapping/release/mapping.txt` with every distributed artifact** — release builds keep line numbers but obfuscate names, so without the mapping file a crash report cannot be retraced. ### R8 / keep rules `app/proguard-rules.pro` is deliberately short and every rule is justified in place; most of the stack (kotlinx.serialization, Hilt, Tink, Firebase, OkHttp, Compose, CameraX) ships its own consumer rules and must not be re-declared. Two rules are load-bearing and were both derived from **observed** failures, not guesswork: - `com.termux.**` is kept whole, because `:terminal-view` binds to non-API internals of the pinned v0.118.0 (the public `TerminalView.mEmulator` field, `TerminalRenderer.getFontWidth()`) and the JitPack artifacts ship no consumer rules. - `implements com.google.firebase.components.ComponentRegistrar { (); }`, because without it R8 strips the reflectively-invoked constructors of ML Kit's registrars and **QR scanning silently stops working in release builds only**. Before adding a rule, read the merged configuration R8 actually used: `app/build/outputs/mapping//configuration.txt`. To validate rules, install the `benchmark` APK and exercise the feature — a wrong keep rule is invisible in debug. ## Instrumented tests `:app` has an androidTest classpath (androidx.test + Espresso + Compose UI test + `hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to `wang.yaojia.webterm.HiltTestRunner`. `app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt` supplies it: ```kotlin class HiltTestRunner : AndroidJUnitRunner() { override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) } ``` A custom runner is **required**, not stylistic: `newApplication` is the only hook that can replace the app-under-test's `Application` with the generated `HiltTestApplication`. Setting `android:name` in the androidTest manifest cannot do it — that manifest is merged into the *test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the class (the runner name is just a manifest value); an actual instrumentation *run* needs it. The suite has been **run green on real hardware** (67/0 instrumented tests against this repo's own server), so `src/androidTest/**` is verified, not aspirational — but it still needs a connected device/emulator and is therefore **not** part of the `./gradlew test` gate; run it with `connectedAndroidTest`. An emulator additionally needs `ALLOWED_ORIGINS=http://10.0.2.2:` on the server, because the server derives its allowed origins from NIC IPs and `10.0.2.2` (the emulator's alias for the host) is not one. ### What is still NOT verified here - **DEFERRED — end-to-end access token**: the `WEBTERM_TOKEN` path is covered by unit tests (`OkHttpAccessTokenTest`, `AuthProbeTest`, `AccessTokenCookieTest`, the `:api-client` route tests, the `AuthCookie` derivation tests) and by `TinkAccessTokenStoreTest` on device, but **no automated leg on the Android side starts a real server with `WEBTERM_TOKEN` set and completes a cookie-gated WS upgrade** — that end-to-end coverage exists only on the iOS side (`ios/IntegrationTests`). - **DEFERRED — the rest of `DEVICE_QA_CHECKLIST.md`**: FCM push delivery under Doze on two physical handsets (S2) and the lock-screen Allow/Deny walkthrough have no automated cover. ## 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. - **A `com.android.test` module cannot use a versioned plugin alias.** The root build script already puts AGP on the classpath via the `android.library`/`android.application` aliases, so a third versioned request for the same artifact fails with *"plugin is already on the classpath with an unknown version"*. `:macrobenchmark` therefore applies `id("com.android.test")` bare — same as `:terminal-view` with `com.android.library`. The version is still pinned once, as `agp` in the catalog. ## Emulator (installed) An AVD named **`webterm`** exists (android-35, arm64, software GPU) and is what produced the on-device evidence recorded in [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md): ```bash export ANDROID_HOME=/usr/local/share/android-commandlinetools $ANDROID_HOME/emulator/emulator -avd webterm -gpu swiftshader_indirect & $ANDROID_HOME/platform-tools/adb devices -l # validate the MINIFIED build (this is what catches bad keep rules) ./gradlew :app:assembleBenchmark adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk adb logcat -c && adb shell am start -W -n wang.yaojia.webterm/.MainActivity adb logcat -d --pid=$(adb shell pidof wang.yaojia.webterm) | grep -E "FATAL|NoSuchMethod|NoClassDefFound" ``` Note it is a **software-GPU** emulator: it is fine for crash/keep-rule/route validation and useless for performance numbers. Real macrobenchmark figures need physical hardware. To add more SDK pieces later: `sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.