Four gaps that made "ship it" and "test it on hardware" impossible. RELEASE SIGNING. assembleRelease was silently producing app-release-unsigned.apk because there was no signingConfigs block at all — the failure mode where you only notice at install time. Credentials now resolve from keystore.properties, then local.properties, then WEBTERM_RELEASE_* env; nothing is created or committed, and keystore.properties.example is the template. The degradation is ENFORCED, not documented. The signingConfig is only created when credentials actually resolve — an empty config is precisely what makes AGP emit an unsigned artifact quietly — and a guard on packageRelease throws with an actionable message, distinguishing "not configured" from "credentials found but the keystore file is missing: <path>". It hooks the PACKAGE task on purpose: R8 has already run and reported by then, so a developer without a keystore still gets the full shrink result rather than an early abort. Verified: minifyReleaseWithR8 completes, packageRelease fails loudly, and no unsigned APK is written. VERSIONING. Was still the scaffold 1 / "0.1.0". Now 1 / "0.1.0-alpha01", where the suffix is a factual claim about device verification rather than decoration: alpha means device QA is essentially unstarted, beta means the A34/A35 checklist blocks pass on hardware, and plain 0.1.0 means the whole checklist is ticked. versionCode stays 1 because no artifact has ever left the build machine. MINIFICATION. release had isMinifyEnabled = false and an empty rules file — so nothing had ever exercised R8 on this app, and the first release build would have been the experiment. Now minify + resource shrinking, with a deliberately short rules file: the actual artifacts were checked, and kotlinx.serialization, Tink, Hilt, FCM, OkHttp, Compose and CameraX all ship their own consumer rules, so none are re-declared. The termux packages are kept whole because the JitPack artifacts ship no rules and :terminal-view binds to non-API internals — it writes the public TerminalView.mEmulator field and calls TerminalRenderer.getFontWidth()/getFontLineSpacing(). Getting that wrong yields a build that crashes only in production. lintVitalRelease also passes now, the two pre-existing lint ERRORS having been fixed earlier this session. ANDROID TEST ENABLEMENT. :app had no androidTest source set and no instrumentation runner, which is the mechanical reason A34/A35 have no code at all. The runner (Hilt-aware) and dependencies are wired, and a :macrobenchmark module exists. The tests themselves are a separate change. Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :app:assembleBenchmark :macrobenchmark:assembleBenchmark :app:lintVitalRelease koverVerify -> BUILD SUCCESSFUL; 901 JVM tests, 0 failures on a forced re-execution with test-results deleted and --no-build-cache.
282 lines
16 KiB
Markdown
282 lines
16 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 |
|
|
| — (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`](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). 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`.
|
|
|
|
⚠️ **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`:
|
|
|
|
```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.
|
|
|
|
## 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"`.
|