Files
web-terminal/android/README.md
Yaojia Wang 97e39949a8 Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
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
2026-07-30 18:12:03 +02:00

20 KiB
Raw Blame History

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).

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 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): :app, :terminal-view, :host-registry, :client-tls-android.
  • Instrumented-only: :macrobenchmark (com.android.test; assembles here, runs only on a device/emulator).

Setup: local.propertiessdk.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*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) 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=<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 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: 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 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. ⚠️ 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:

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:

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