Closes the six remediation items from the 2026-07-29 iOS completion audit plus the whole P2 wave and Android access-token parity. The audit's headline was that the client was code-complete but stuck at the device door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware once, and it had fallen two months behind the server (Android had the git panel, iOS had none) while neither native client could connect at all once WEBTERM_TOKEN was set. Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49% and is now actually in the coverage gate, which it never was. Device build now succeeds on the free personal team. src/ and public/ are untouched — the git-panel endpoints already existed server-side; iOS simply never consumed them. # Conflicts: # android/.gitignore # android/README.md # android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt # android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt # android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt # android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt # android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt # docs/PROGRESS_LOG.md
345 lines
20 KiB
Markdown
345 lines
20 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).
|
||
|
||
> **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=<t>` 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 `<profileable android:shell="true"/>` 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 `<server-line>-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 { <init>(); }`, 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/<variant>/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:<port>` 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"`.
|