Files
web-terminal/android
Yaojia Wang 5cc755b0b6 fix(ios,android): close the acceptance gaps the review found, add Android CI
- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar
  froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped —
  recorded as a withKnownIssue rather than fixed. Height now derives from the
  content size category (and tracks live changes via registerForTraitChanges);
  the known-issue marker is gone, replaced by positive assertions including a
  12-category no-clip sweep and a guard that stays red if anyone writes the
  constant back. Honest tradeoff: the keycap font is clamped at .accessibility2,
  the same policy the design system already applies to dense content, because an
  unclamped AX5 bar would eat the terminal. A test pins the clamp so the two
  cannot drift.

- Thumbnails silently 401'd on a token-gated host: the pipeline built its own
  transport with no token source, and by design never throws, so every preview
  degraded to a placeholder with no signal. Assembled from AppEnvironment now.

- Android had zero CI while the token wave shipped 24 files of secret-handling
  code. The instrumented leg is workflow_dispatch-only and says why in the file:
  no one has ever seen it green on a runner, and a required leg nobody trusts
  just produces a false green.

- Android persisted a validated token before the host probe succeeded, stranding
  a secret for a host that never paired.

App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691.
2026-07-30 16:46:20 +02:00
..

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 (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): :app, :terminal-view, :host-registry, :client-tls-android.

Setup: local.propertiessdk.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*Transports 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: 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

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