The instrumented suite was run on a device for the first time and the app died
on the first test:
ArrayIndexOutOfBoundsException: length=31; index=31
at com.termux.terminal.TerminalRow.getStyle
at com.termux.view.TerminalRenderer.render
at com.termux.view.TerminalView.onDraw
PROGRESS_ANDROID.md had classified exactly this as "Accepted (not a defect):
steady-state append runs concurrently with the UI-thread onDraw ... torn read
self-corrects next frame". Both halves of that were wrong.
It is not cosmetic. TerminalRenderer.render caches its column bound once from
TerminalEmulator.mColumns and then indexes TerminalRow.getStyle(column) into a
raw long[] sized when that row was allocated. TerminalEmulator.resize publishes
the new mColumns BEFORE resizeScreen() reallocates the rows, so a draw landing
in that window reads the new bound against old-width rows. The first
out-of-range column is exactly oldColumns, which is why the exception was
always length == index — the consistency was a deterministic signature of a
grow, not a rare interleaving. A shrink is harmless, which is probably why
"self-corrects" looked plausible. TerminalBuffer.resize is non-atomic in
several more ways, and TerminalRow.setChar swapping mText for a larger array is
the same hazard on the append path.
Nor does it match upstream. Upstream's background reader only fills a
ByteQueue; TerminalSession$MainThreadHandler is what calls append, and
TerminalView.updateSize resizes — both on the main thread. Upstream has no
concurrent reader at all. There is also no lock to lean on: monitorenter
appears nowhere in TerminalEmulator, TerminalBuffer, TerminalRow or
TerminalRenderer.
The race was dormant until this session. updateSize had no call sites, so
mColumns never changed after bind and there was never a width mismatch to tear
on. Making resize work is what made this reachable — the same lesson as the
rest of this pass: the green suite could not see any of it because no JVM test
instantiates a View.
Fix: every emulator mutation now runs on the render thread, from inside the
same single confined consumer. §6.2 is intact — still one writer draining one
ordered channel, suspending across the main hop, so a resize and an append
still cannot interleave and submission order still holds. They are now also
serialised against onDraw, because one Handler work item cannot run inside
another. Resize still reaches the wire and the §6.4 forced re-assert still
bypasses the dedup.
Chunking becomes load-bearing rather than incidental: each 4 KiB slice is its
own main-thread work item, so a multi-MB ring replay interleaves with frames
and input instead of blocking behind one long call — which is what §6.2's
no-ANR requirement actually needs, rather than "runs off the main thread".
Gating the child's dispatchDraw was considered and rejected: it would have to
cover appends too, leaving a choice between blanking the terminal and blocking
the UI thread on a full reflow. A read/write lock was rejected because append
calls back out through TerminalOutput (title, bell, DA/DSR reply), so holding a
write lock across it would bury a deadlock invariant in app-level callbacks.
The KDoc that made the false claims now states the mechanism with offsets, and
RemoteTerminalHostView records why dispatchDraw is deliberately not gated.
Verified: the device crash is reproduced as a JVM test first (red), so this
regression is now guarded without a device. ./gradlew test :app:assembleDebug
koverVerify -> 903 tests, 0 failures. On the emulator the previously-crashing
test now passes and 5 of 6 alternate-screen scroll tests pass.
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.mdis ticked. Version is0.1.0-alpha01for 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.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 |
| — (no iOS counterpart) | :macrobenchmark |
com.android.test harness |
⬜ scaffolded, no sources |
: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),
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 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-tlspure half). TDD, immutable data, small focused files — same discipline as the rest of the repo.Do not use
--rerun-tasksin a release gate. It trips an AGP lint/K2 internal bug (FirDeclaration was not found for class KtProperty, fir is null, onThumbnailPipeline.kt).lintVitalReleasepasses 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".
-
versionCodeis 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 fromversionName; keeping them independent is what lets a hotfix ship without renumbering. It is still1because no artifact has ever left the build machine; the first distributed build takes2. -
versionNameis<server-line>-alphaNN. The client tracks the server's0.1.xline (rootpackage.jsonis0.1.0). The-alpha01suffix is a factual claim about device verification, not marketing:-alphaNN— builds, minifies, JVM-tested; device QA essentially unstarted. ← today-betaNN— the A34/A35 blocks ofDEVICE_QA_CHECKLIST.mdpass 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-alphaNNonly 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:
android/keystore.properties— the conventional path. Copykeystore.properties.example. ⚠️ Confirmkeystore.propertiesis listed inandroid/.gitignorebefore creating it. The existing rules cover*.jks/*.keystore/*.p12but not this filename.android/local.properties— already gitignored, so it needs no new rule. Same four keys.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-viewbinds to non-API internals of the pinned v0.118.0 (the publicTerminalView.mEmulatorfield,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.
⚠️ That runner class does not exist yet — it is the first file the A34 author must write,
into app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt:
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.
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. -
A
com.android.testmodule cannot use a versioned plugin alias. The root build script already puts AGP on the classpath via theandroid.library/android.applicationaliases, so a third versioned request for the same artifact fails with "plugin is already on the classpath with an unknown version".:macrobenchmarktherefore appliesid("com.android.test")bare — same as:terminal-viewwithcom.android.library. The version is still pinned once, asagpin 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".