This file's cross-review was explicitly skipped when it was written — PROGRESS_ANDROID.md says so — and it had no production call sites until this session, so nothing had ever reviewed OR executed it. Wiring it to the home screen finally made an adversarial concurrency review worthwhile. It confirmed the hard parts are right (the check-then-insert dedup critical section, withPermit's exactly-once release with the fallback outside it, the NonCancellable finally, and rethrowing CancellationException) and then found five real defects. Every premise was re-verified from the termux v0.118.0 bytecode before fixing. 1. Thumbnails drew DUPLICATED ROWS. TerminalBuffer.externalToInternalRow is `(mScreenFirstRow + row) % mTotalRows` and mTotalRows is the constructor's transcriptRows, so passing TERMINAL_TRANSCRIPT_ROWS_MIN (100) while rows went up to 200 aliased external rows 100..199 onto 0..99 — and left the emulator's scroll state invalid during append(). A pure ThumbnailGrid now derives transcriptRows = max(rows, MIN). 2. A legitimately-sized session was LMK bait. cols/rows are server-supplied and the protocol validates resize only to [1,1000], so a 1000x1000 session forced 2800x2800 ARGB_8888 = 31.4 MB, twice over for two permits, every poll tick. Rather than clamp the grid — which would CROP a real screen — the cell size is now derived from the target, shrinking the font's own cell by an exact integer fraction with the aspect preserved. Any geometry in 1..1000 now yields at most 480x480 = 900 KiB; a real 161x50 goes from 3.1 MB to 322 KiB. 3. Two stalled fetches wedged the feature PERMANENTLY. There is no OkHttp callTimeout (the per-socket read timeout is reset by every trickled byte) and the render is deliberately uncancellable by any caller, so two slow-trickle previews held both permits until close() and no thumbnail rendered again. A withTimeout now sits inside withPermit, budgeting the work rather than the permit wait. The subtlety that makes this correct: TimeoutCancellationException IS a CancellationException, so it is caught BEFORE that branch and routed to the placeholder — otherwise a slow host would still kill the pipeline instead of degrading. 4. The in-flight entry was leakable, contradicting a comment that claimed it never was. `scope.async` on an already-cancelled scope never runs its body, so the finally never ran: a post-close() request left a dead Deferred that every later request awaited forever, and inFlight grew unbounded. Now guarded inside the mutex, and the comment names the one path that genuinely cannot release (where the map dies with the pipeline anyway). 5. Obsolete renders did guaranteed wasted work. lastOutputAt advances on every PTY byte and the list polls every 5s, so an active session mints a new key each tick; the row cancelled the old caller but the old render ran a full GET plus a full raster whose bitmap was cached under a key nothing would request again — occupying both permits ahead of the key actually on screen, and growing without bound once render latency exceeded arrival rate. A newer key now cancels the older in-flight render for that session. Also made two false statements true rather than leaving them: retainOnly now prunes under the same monitor publish() takes, so a render landing after the prune cannot resurrect a killed session's bitmap; and PreviewCap's KDoc no longer claims to prevent an oversized-body OOM (the body is already buffered and parsed by then) nor to "mirror the server's cap" (the server defaults to 24 KiB, not 256 KiB, and an operator can raise it unbounded). RED was observed for all four behavioural fixes before implementing. Verified: ./gradlew test :app:testDebugUnitTest :app:assembleDebug koverVerify -> BUILD SUCCESSFUL, 901 JVM tests, 0 failures (893 -> 901). The agent distrusted a FROM-CACHE green, forced a real execution, and confirmed the code reached the packaged APK by scanning its dex.
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.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 |
Not yet scaffolded:
:transport-okhttp(OkHttpTermTransport/HttpTransportimpls, JVM) is owned by task A7 and will be added then. The iOSURLSession*Transports consolidate into it (plan §3 framing note).
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),
and the TermTransport / HttpTransport / PingableTermTransport boundary
interfaces. New wire types are added only here (a coordination point).
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-tlspure half). TDD, immutable data, small focused files — same discipline as the rest of the repo.
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 viabrew install --cask android-commandlinetools). - Installed packages:
platform-tools,platforms;android-35,platforms;android-36,build-tools;35.0.0,build-tools;36.0.0. (:appcompiles against SDK 36 — the Kotlin-2.3.21-contemporaneous androidx/Compose line refuses SDK 35;platforms;android-37is 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 bothpluginManagementanddependencyResolutionManagementinsettings.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.androiderrors with "no longer required since AGP 9.0". - Module block:
android { namespace = "…"; compileSdk = 36; defaultConfig { minSdk = 29 } }. (Framework modules targetcompileSdk = 36;targetSdkstays35per plan §2.) :appUI-stack version matrix (A13, proven:app:assembleDebuggreen): AGP 9.2.1 · Kotlin 2.3.21 · Compose-compiler pluginorg.jetbrains.kotlin.plugin.compose= 2.3.21 · Compose BOM2025.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 KSP2.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(NEVERkotlin.android). Bump these together withcompileSdk 37once 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".