From c1612d01450e08c2604edb5175367fe07d4e8601 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 12:14:53 +0200 Subject: [PATCH] build(android): make release buildable and instrumented testing possible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ". 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. --- android/DEVICE_QA_CHECKLIST.md | 182 +++++++++++++++++++++--- android/README.md | 168 ++++++++++++++++++++-- android/app/build.gradle.kts | 178 ++++++++++++++++++++++- android/app/proguard-rules.pro | 121 +++++++++++++++- android/gradle/libs.versions.toml | 44 +++++- android/keystore.properties.example | 31 ++++ android/macrobenchmark/build.gradle.kts | 89 ++++++++++++ android/settings.gradle.kts | 7 + 8 files changed, 784 insertions(+), 36 deletions(-) create mode 100644 android/keystore.properties.example create mode 100644 android/macrobenchmark/build.gradle.kts diff --git a/android/DEVICE_QA_CHECKLIST.md b/android/DEVICE_QA_CHECKLIST.md index 060b3b6..1814c66 100644 --- a/android/DEVICE_QA_CHECKLIST.md +++ b/android/DEVICE_QA_CHECKLIST.md @@ -1,19 +1,133 @@ # Android client — device-QA checklist (A36 / plan §7) -Everything below is **device/emulator-only** — it could NOT run in the build environment (no emulator, -no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests, -Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping. +Everything below is **device/emulator-only**: it cannot be proven by the JVM suite. The pure logic +underneath each item IS unit-tested (757 JVM tests across 10 modules, Kover ≥80% on the pure modules), +but the 2026-07-30 audit established the hard lesson this file now exists to enforce — **a green JVM +suite says nothing about whether the app works.** Three crash-on-first-keypress defects and a +cleartext posture that made bare-LAN connection impossible all sat behind 484 passing tests, because +no JVM test instantiates an Android `View` and no JVM test dials a socket. + +**Rules for this file:** tick a box ONLY after observing the behaviour on real hardware (or, where +noted, an emulator) — and say what you observed. Never tick something because the code looks right or +a unit test covers it. Unticked is the honest default. + +--- + +## Observed so far (the only on-device evidence that exists) + +**2026-07-30, minified `benchmark` variant, `webterm` AVD (android-35, arm64, software GPU):** + +- [x] The **R8-minified, resource-shrunk, non-debuggable APK installs and cold-starts.** `am start -W` + → `Status: ok`, `LaunchState: COLD`, `TotalTime: 1085 ms` (software-GPU emulator — not a + meaningful performance number, only proof of a clean start). Process stayed alive; + `logcat -b crash` empty; no `FATAL EXCEPTION`, `NoClassDefFoundError` or `ClassNotFoundException`. + This is the check that R8 keep rules are correct — the class of bug that appears ONLY in release. +- [x] **Cold-start route with no paired host → pairing screen**, rendered correctly under R8 + (`ColdStartPolicy`, A29): title 配对主机, the 手动输入/扫码 segmented control, the + `主机地址(http(s)://…)` field and a disabled 下一步 button. Compose + Material 3 + the design-token + theme + Hilt injection therefore all survive minification. +- [x] **`POST_NOTIFICATIONS` runtime prompt appears** on first launch (API 33+ path, A30). +- [x] **ML Kit component registration** no longer fails under R8 (`ComponentDiscovery` clean). Before + the `ComponentRegistrar` keep rule it logged + `NoSuchMethodException: com.google.mlkit.common.internal.CommonComponentRegistrar. []`, + which would have silently disabled **QR barcode scanning in release builds only**. + +**Expected/benign in the same run:** `W FirebaseApp: Default FirebaseApp failed to initialize because +no default options were found` — there is no `google-services.json` yet (see the deploy artifacts +below). A `System UI isn't responding` dialog also appeared; that is the emulator's own `com.android.systemui` +under software GPU, not this app (our process logged no ANR and stayed `topResumedActivity`). + +**Everything else in this file is unobserved.** Notably: no real WebTerm host has ever been connected +to from Android, no terminal bytes have ever been rendered on a device, and no push has ever arrived. + +--- ## Deploy artifacts to provide first (not built here) +- [ ] **Release signing credentials.** `:app:assembleRelease` deliberately FAILS at packaging until they + exist — it will never emit an unsigned APK. Provide `webterm.release.{storeFile,storePassword,keyAlias,keyPassword}` + in `android/keystore.properties` (see `keystore.properties.example`; confirm it is gitignored + first) or `android/local.properties`, or the `WEBTERM_RELEASE_*` env vars. Keep the keystore + out of the repo — a lost signing key cannot be recovered. - [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its absence with `runCatching`, so the app runs without it; push just won't register). - [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately omitted it — applying it without the json fails the build). - [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert - SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the - `FCM_*` env group (service-account key path + project id). + SHA-256 (for the verified App Link `autoVerify`). Read it with + `keytool -list -v -keystore -alias | grep SHA256`. Server-side: run `A33` + `src/push/fcm.ts` with the `FCM_*` env group (service-account key path + project id). + +--- + +## FIRST PRIORITY — confirm the 2026-07-30 blocker fixes on a device + +These were the defects that made the app unusable on hardware. All are fixed and JVM-verified; **none +has been exercised on a device.** Until this block is ticked, nothing further in this file is worth +running, because every one of these sits on the path to the terminal. + +- [ ] **Typing does not crash.** Soft-keyboard keys, Enter, Backspace via the installed + `WebTermTerminalViewClient` / `RemoteTerminalHostView` IME contract. (Was: NPE on the first + keypress — stock `TerminalView.onKeyDown` dereferences `mClient`, then `mTermSession`.) +- [ ] **Swipe-scrolling inside an alternate-screen TUI does not crash.** Claude Code IS an + alternate-screen TUI, so this is ordinary use, not an edge case: drag vertically while a Claude + session is running. Also test the fling and, on a device with a mouse/trackpad, wheel scroll + (`onGenericMotionEvent`). (Was: `doScroll` → `handleKeyCode` → `mTermSession` NPE.) +- [ ] **A password manager does not crash the app.** Open the app with an autofill service enabled and + focus the terminal. (Was: unguarded `autofill()` while the view advertised itself autofillable; + the subtree is now excluded from the autofill structure.) +- [ ] **`resize` actually reaches the PTY.** A full-screen TUI fills the screen instead of rendering + into an 80x24 box, and reflows on rotation. (Was: `updateSize` had zero call sites.) Confirm the + cols×rows the server reports match what `TerminalResizeDriver` computed from + `TerminalRenderer.getFontWidth()/getFontLineSpacing()`. +- [ ] **Bare-LAN `ws://` connects.** Pair with `http://:3000` and attach. (Was: + `network_security_config.xml` was a stub denying all cleartext while `HostEndpoint` derives + `ws://` from `http://`.) +- [ ] **The tunnel domain is still TLS-only.** `http://.terminal.yaojia.wang` must be refused + (the one hostname with `cleartextTrafficPermitted="false"`), and `OkHttpClientFactory` must + refuse a plaintext dial to a non-private host even if the config would allow it. +- [ ] **§6.4 device-switch reclaim.** Attach the same session from a phone and a tablet; the device you + last touched drives the PTY size (latest-writer-wins) and reclaims full screen on + pane-show/window-focus without a re-attach. +- [ ] **`WEBTERM_TOKEN` gate end-to-end** against a server started WITH the token: REST + the WS + upgrade both carry the cookie, it survives a process restart (sealed with Tink AEAD under an + AndroidKeyStore key), and a **seal failure persists nothing** rather than falling back to + plaintext. Also verify the cookie never appears in `logcat`. + ⚠️ **Blocked until the cookie jar + cipher are installed in `:app`'s DI** — until then the gate + is inert at runtime no matter what the server does. +- [ ] **Newly-wired surfaces that had ZERO call sites before this session** — each needs a first-ever + look: session-list **preview thumbnails** (`ThumbnailPipeline`), **quick-reply chips + palette** + (`QuickReply`), and the tablet **pointer context menu** (`PointerContextMenu`). +- [ ] **Newly-decoding server frames.** The `queue` frame ("N queued" badge) and `status.preview` were + both being dropped as undecodable — the latter meant remote approvals were **blind**. Confirm a + real gate now shows the command being approved, and that a malformed preview does not cost the + whole frame (the gate must still appear). + +--- + +## KNOWN FEATURE GAP — copy-out via text selection is unavailable (deliberate) + +**Not a bug to be fixed by re-enabling stock selection.** At the pinned Termux v0.118.0, the stock +`ActionMode`'s own handlers dereference the `TerminalSession` this app deliberately does not have +(`TextSelectionCursorController$1.onActionItemClicked`, offsets 89-100 for Copy and 126-136 for Paste). +`TerminalSession` is `final` and its constructor forks a local process over JNI — exactly what a remote +terminal must not do (plan §6.1). So making the selection UI reachable would put a guaranteed NPE on +the **Copy** button, which is worse than not offering it. Long press is therefore consumed and +selection never starts. + +**The real fix** (not built, tracked): a first-party selection layer — hit-test to a cell, own the +selection anchors, read the text straight out of `TerminalBuffer`, and write it with `ClipboardManager`. +Paste-in is unaffected and already works. + +- [ ] Confirm on a device that long press is inert and does **not** show a broken Copy affordance. + +--- + +## A34 — instrumented E2E vs a real `npm start` host + +Infrastructure now EXISTS (`:app` has androidTest deps, a Hilt-aware `testInstrumentationRunner`, and +Espresso + Compose-UI-test on the classpath) but **the tests themselves are not written**. See +`android/README.md` → "Instrumented tests" for the one runner class that must be authored first. -## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device) - [ ] `attach → attached → output` round-trip timing. - [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay. - [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy. @@ -22,15 +136,19 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h - [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS. ## A35 — one macrobenchmark / Espresso happy path + +The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumenting `:app`'s +`benchmark` variant out-of-process); it has **no sources yet**. + - [ ] pair → attach → type → approve, on a real device. +- [ ] cold-start timing on real hardware (`StartupTimingMetric`). The 1085 ms above is a software-GPU + emulator number and must not be quoted as a baseline. ## Terminal (A16/A17/A21) - [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8 WebView fallback ONLY if fidelity diverges — not expected). -- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap. -- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are - package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); resize parity vs - web/iOS on the same device sizes (R5). +- [ ] IME/CJK composition; http/https link tap. (Selection→clipboard is the gap above, not a test.) +- [ ] **real font-metric → grid resize** and resize parity vs web/iOS on the same device sizes (R5). - [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background → generation bump → fresh emulator replays. @@ -47,22 +165,27 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h - [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth success; cancel/error/no-enrolled-auth → no POST (fail-safe). - [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency). -- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals - on rotation / new host / removal. +- [ ] token registers to every paired host + self-heals on rotation / new host / removal. +- [ ] **In a MINIFIED build specifically** — FCM and ML Kit both discover components reflectively, so + re-verify push registration and QR scanning on the `benchmark`/release variant, not just debug. ## Pairing / cert / storage (A19/A27/A11/A12) -- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. +- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. **Run this on a minified + variant too** (see the `ComponentRegistrar` note above — this path is the one R8 already broke once). - [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't bypass); public host explicit-ack. - [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove. +- [ ] **Cert summary does not crash** — see the `javax.naming` defect under "Known gaps"; the issuer-CN / + expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY. - [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited. ## Nav / deep links / adaptive (A32/A26/A29) - [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the - right destination; invalid UUID ignored. -- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens. + right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and + `assetlinks.json`.) +- [ ] continue-last-session banner re-opens. (The no-host → pairing half is observed above.) - [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` + `ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600). @@ -85,11 +208,32 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ; **push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited). -## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md) +--- + +## Known gaps (tracked, non-blocking for QA but must be routed to an owner) + +- [ ] **`:client-tls` uses `javax.naming.ldap.LdapName`, which does not exist on Android.** Found by R8 + (`Missing class javax.naming.ldap.LdapName ... referenced from CertificateSummaryReader.commonName`). + On-device that call throws `NoClassDefFoundError`, an **`Error`** — so it slips straight through the + function's `catch (_: Exception)` guard and propagates out of `summarize()`. The module is pure-JVM + and its unit tests run on a JDK where `LdapName` DOES exist, which is exactly why the green suite + never caught it. Effect: the device-cert screen's issuer-CN/expiry summary (A27) crashes. + Fix: hand-parse the RFC2253 DN (or at minimum catch `Throwable`). The `-dontwarn` in + `proguard-rules.pro` only unblocks R8; it changes nothing at runtime. +- [ ] **The auth cookie jar + cipher are not installed in `:app`'s DI**, so the `WEBTERM_TOKEN` gate is + inert at runtime even though every piece is implemented and tested. - [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't carry the sessionId; the gate is still visible in the terminal). MEDIUM. - [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link. - [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked). -- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView` - is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to - `:app` and delete the shim. +- [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a + `dataExtractionRules` `` xml resource. Until then a D2D migration could copy the + sealed auth cookie to another handset. +- [ ] AGP's lint crashes with `FirDeclaration was not found for class KtProperty, fir is null` on + `ThumbnailPipeline.kt` when run under `--rerun-tasks`. `lintVitalRelease` passes normally and from + a cold lint state; this is a lint/K2 internal bug (lint says so itself), not an app defect. Do not + use `--rerun-tasks` in a release gate. + +> RESOLVED since the last revision: the A21 reflection shim (`getMethod("getTerminalView")`) is **gone** — +> `RemoteTerminalHostView` now holds the stock `com.termux.view.TerminalView` directly, so there is no +> reflective getter left to break under R8 and no keep rule is needed for one. diff --git a/android/README.md b/android/README.md index 0a0a431..67e1db1 100644 --- a/android/README.md +++ b/android/README.md @@ -9,6 +9,13 @@ This directory is a **Gradle multi-module** project. The module set mirrors the 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 @@ -19,10 +26,12 @@ against SDK 35/36. `: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 koverVerify`. +`./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest koverVerify`. ## Module map (mirror of the iOS SPM packages — plan §3) @@ -37,10 +46,15 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools | 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 | -> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport` -> impls, JVM) is owned by task **A7** and will be added then. The iOS -> `URLSession*Transport`s consolidate into it (plan §3 framing note). +`: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") @@ -82,15 +96,124 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`). ```bash # 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) +./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 `` 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 `-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 { (); }`, 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//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) @@ -127,5 +250,32 @@ AGP library module compiled against SDK 35 and produced an AAR): `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): +- **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"`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8010a60..b244242 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -10,6 +10,8 @@ // com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android // ───────────────────────────────────────────────────────────────────────────── +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) @@ -17,6 +19,83 @@ plugins { alias(libs.plugins.hilt) } +// ───────────────────────────────────────────────────────────────────────────── +// Release signing credentials — NEVER committed. +// +// Looked up in this order, first non-blank wins: +// 1. `android/keystore.properties` (the conventional path; see keystore.properties.example) +// 2. `android/local.properties` (already gitignored — the zero-setup local option) +// 3. `WEBTERM_RELEASE_*` env vars (CI) +// +// DEGRADATION CONTRACT: absent credentials leave debug (and the `benchmark` variant, +// which signs with the debug key) fully working, and make the RELEASE package task fail +// with RELEASE_SIGNING_HELP — never a silent `app-release-unsigned.apk`. +// ───────────────────────────────────────────────────────────────────────────── +val signingPropertyFiles = listOf("keystore.properties", "local.properties") + +val signingProperties = Properties().apply { + // Later files must not override earlier ones, so load in reverse and let the + // higher-priority file overwrite. + signingPropertyFiles.reversed() + .map(rootProject::file) + .filter(File::exists) + .forEach { file -> file.inputStream().use(::load) } +} + +fun signingCredential(propertyKey: String, envKey: String): String? = + (signingProperties.getProperty(propertyKey) ?: System.getenv(envKey))?.takeIf(String::isNotBlank) + +val releaseStoreFile = signingCredential("webterm.release.storeFile", "WEBTERM_RELEASE_STORE_FILE") +val releaseStorePassword = signingCredential("webterm.release.storePassword", "WEBTERM_RELEASE_STORE_PASSWORD") +val releaseKeyAlias = signingCredential("webterm.release.keyAlias", "WEBTERM_RELEASE_KEY_ALIAS") +val releaseKeyPassword = signingCredential("webterm.release.keyPassword", "WEBTERM_RELEASE_KEY_PASSWORD") + +// `storeFile` may be absolute or relative to `android/` — rootProject.file handles both. +val releaseKeystore = releaseStoreFile?.let(rootProject::file) + +val hasAllReleaseCredentials = + releaseStoreFile != null && + releaseStorePassword != null && + releaseKeyAlias != null && + releaseKeyPassword != null + +val isReleaseSigningConfigured = hasAllReleaseCredentials && releaseKeystore?.exists() == true + +// Two distinct failures deserve two distinct messages: "you have not set this up" and +// "you set it up but pointed it at a file that isn't there" have completely different fixes. +val MISSING_KEYSTORE_HELP = """ + |:app release signing credentials were found, but the keystore file does not exist: + | ${releaseKeystore?.absolutePath} + | + |Fix `webterm.release.storeFile` (absolute, or relative to the `android/` directory), or + |point WEBTERM_RELEASE_STORE_FILE at the real file. Nothing was signed, and no unsigned + |APK was emitted. +""".trimMargin() + +val UNCONFIGURED_SIGNING_HELP = """ + |:app release signing is NOT configured — refusing to emit an unsigned release artifact. + | + |Provide the four credentials in ONE of these places (first match wins): + | 1. android/keystore.properties — copy android/keystore.properties.example. + | MAKE SURE `keystore.properties` is in android/.gitignore first. + | 2. android/local.properties — already gitignored; zero extra setup. + | 3. env: WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI) + | + |Keys (property form): + | webterm.release.storeFile= + | webterm.release.storePassword=<...> + | webterm.release.keyAlias=<...> + | webterm.release.keyPassword=<...> + | + |No keystore yet? Generate one OUTSIDE the repo: + | keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \ + | -alias webterm -keyalg RSA -keysize 4096 -validity 10000 + |Then record its SHA-256 in the App Links assetlinks.json (plan §8). + | + |Unaffected by this failure: `:app:assembleDebug`, `:app:assembleBenchmark`, + |`:app:assembleDebugAndroidTest`, every unit test, and R8 itself (which already ran). +""".trimMargin() + android { namespace = "wang.yaojia.webterm" // compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for @@ -28,24 +107,87 @@ android { applicationId = "wang.yaojia.webterm" minSdk = 29 targetSdk = 35 + // ── Versioning policy ──────────────────────────────────────────────── + // versionCode: a plain monotonic counter, +1 for EVERY artifact handed to + // anyone (Play track, internal APK, benchmark run that gets archived). + // Never derived from versionName — decoupling them is what lets a hotfix + // ship without renumbering. This is still 1 because no artifact has ever + // left this machine; the first distributed build takes 2. + // versionName: `-alphaNN`. The Android client tracks the server's + // 0.1.x line (root package.json is 0.1.0). `-alpha01` is the honest state: + // the P0+P1 surface from ANDROID_CLIENT_PLAN §1 is implemented and JVM-tested, + // but NOTHING in DEVICE_QA_CHECKLIST.md has been signed off on real hardware. + // Drop to `-beta01` when the checklist's A34/A35 blocks pass on a device, and + // to plain `0.1.0` when the whole checklist is ticked. versionCode = 1 - versionName = "0.1.0" + versionName = "0.1.0-alpha01" + + // Hilt instrumented testing REQUIRES a custom runner: AndroidJUnitRunner is the + // only hook that can swap the app-under-test's Application for the generated + // `HiltTestApplication` (the androidTest manifest cannot — it is merged into the + // TEST apk, not into the app under test). See android/README.md → "Instrumented tests". + testInstrumentationRunner = "wang.yaojia.webterm.HiltTestRunner" } buildFeatures { compose = true } + signingConfigs { + // Populated ONLY when credentials were found; an empty config would make AGP + // fall back to emitting `app-release-unsigned.apk`, which is exactly the silent + // failure this block exists to prevent (the release buildType leaves + // `signingConfig` null instead, and the guard task below fails the build). + if (isReleaseSigningConfigured) { + create("release") { + storeFile = rootProject.file(releaseStoreFile!!) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + // v1 (JAR signing) is only needed below API 24 and is the weakest scheme — + // off. v2/v3 are left enabled and apksigner picks what the minSdk range + // actually requires: verified output for minSdk 29 is v1=false, v2=false, + // v3=true (every device that can install this understands v3, so v2 is + // redundant). Do not read v2=false as a misconfiguration. + enableV1Signing = false + enableV2Signing = true + enableV3Signing = true + } + } + } + buildTypes { debug { // No applicationId suffix — keep it stable for deep-link testing (A32). } release { - isMinifyEnabled = false + isMinifyEnabled = true + // Resource shrinking requires minification; it is what strips the unused + // CameraX/ML-Kit/Firebase resources the 56 MB unminified APK carried. + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + signingConfig = signingConfigs.findByName("release") + } + // `benchmark` — what :macrobenchmark (A35) measures against. It must match the + // shipping variant in everything that affects performance (non-debuggable, R8'd, + // resource-shrunk) while needing NO release keystore, so it signs with the debug + // key. It is therefore also the variant that proves the R8 pipeline end-to-end + // (`:app:assembleBenchmark` produces a real, installable, minified APK). + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + // Library modules only publish debug/release; resolve `benchmark` to release. + matchingFallbacks += listOf("release") + isDebuggable = false + // Injects into the merged manifest, which + // macrobenchmark needs to read traces on API 29-30 (implicit from API 31). + // Done here BECAUSE AndroidManifest.xml must not carry a benchmark-only tag. + isProfileable = true + isMinifyEnabled = true + isShrinkResources = true } } @@ -114,9 +256,41 @@ dependencies { testImplementation(libs.bundles.unit.test) testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6) testRuntimeOnly(libs.junit.platform.launcher) + + // ── Instrumented tests (androidTest — device/emulator only; plan §7) ────────── + // Homes A34 (E2E against a real `npm start` host) and the Espresso/Compose half of + // A35. JUnit4 only: AndroidJUnitRunner cannot drive the JUnit5 platform. + androidTestImplementation(libs.bundles.android.instrumented.test) + // Compose UI assertions (gate banner / plan sheet / reconnect-banner precedence / + // session rows / continue-last banner). BOM-managed, same BOM as main. + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + // Supplies the empty host Activity that `createComposeRule()` launches into. Must be + // debugImplementation (it contributes a manifest entry to the app under test). + debugImplementation(libs.androidx.compose.ui.test.manifest) + // Hilt instrumented testing: HiltAndroidRule + the generated HiltTestApplication. + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.compiler) } // Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules. +// Instrumented (androidTest) tasks are NOT of type Test, so they keep JUnit4. tasks.withType().configureEach { useJUnitPlatform() } + +// ───────────────────────────────────────────────────────────────────────────── +// Release-signing guard. +// +// Hooked as a doFirst on the PACKAGE task (not as a dependency) on purpose: packaging +// runs after dexing, so R8/resource-shrinking have fully completed and been reported by +// the time this fires. An unconfigured machine therefore still gets a real, verifiable +// R8 run — it just cannot produce a distributable artifact. +// ───────────────────────────────────────────────────────────────────────────── +if (!isReleaseSigningConfigured) { + val help = if (hasAllReleaseCredentials) MISSING_KEYSTORE_HELP else UNCONFIGURED_SIGNING_HELP + tasks.matching { it.name == "packageRelease" || it.name == "packageReleaseBundle" } + .configureEach { + doFirst { throw GradleException(help) } + } +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 45f9aa2..acb163c 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,4 +1,117 @@ -# WebTerm :app — R8/ProGuard rules. -# Release is not minified yet (isMinifyEnabled = false); this file exists so the -# release buildType's proguardFiles(...) reference resolves. Real keep-rules for -# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later. +# ───────────────────────────────────────────────────────────────────────────── +# WebTerm :app — R8 keep rules (release + benchmark; both isMinifyEnabled = true). +# +# DISCIPLINE: every rule below is justified, and rules that turned out to be +# UNNECESSARY are recorded as such rather than added "just in case". A superfluous +# -keep silently costs size and hides real reachability problems; a MISSING one +# crashes only in production. Both were checked against the merged configuration +# R8 actually used, which is dumped to: +# app/build/outputs/mapping//configuration.txt +# Read that file before adding anything here — most of our stack already ships its +# own consumer rules, and duplicating them is how this file rots. +# +# ALREADY COVERED BY EMBEDDED CONSUMER RULES — do NOT re-declare here: +# · kotlinx.serialization — kotlinx-serialization-core-jvm ships +# META-INF/com.android.tools/r8/kotlinx-serialization-{common,r8}.pro, which keeps +# @Serializable classes' `Companion` fields, `serializer(...)`, object `INSTANCE`, +# the `$$serializer.descriptor` field, and RuntimeVisibleAnnotations (the R8 +# full-mode fix). Verified present in 1.9.0. Our @Serializable models live in +# :wire-protocol / :api-client; the rules are global, so they are covered. +# · Hilt / Dagger — hilt-android + dagger AARs ship proguard.txt; all injection is +# generated code with compile-time references, no runtime reflection to preserve. +# · Tink — tink-android ships META-INF/proguard/protobuf.pro, keeping on +# subclasses of the shaded protobuf GeneratedMessageLite (the reflective lite +# runtime). Tink's key managers are registered explicitly by AeadConfig.register(), +# not discovered reflectively, so nothing else needs keeping. +# · FCM — firebase-messaging ships consumer rules, AND `.push.FcmService`, +# `.push.DenyBroadcastReceiver`, `.push.AllowTrampolineActivity`, `.MainActivity` +# and `.WebTermApp` are all declared in AndroidManifest.xml, for which AGP +# auto-generates keep rules (build/intermediates/aapt_proguard_file/.../aapt_rules.txt). +# Manifest-declared components therefore need no rule here — ever. +# · OkHttp / Okio, Compose, CameraX, ML Kit barcode, DataStore, navigation-compose, +# biometric — all ship their own consumer rules. +# · JNI — the default proguard-android-optimize.txt already contains +# `-keepclasseswithmembernames class * { native ; }`, which covers the +# Termux emulator's native entry points even though we never fork a subprocess. +# ───────────────────────────────────────────────────────────────────────────── + + +# ── Termux terminal emulator + view (the ONE thing that genuinely needs a rule) ── +# +# Why this is not "just in case": +# 1. The JitPack artifacts (com.github.termux.termux-app:terminal-{emulator,view}, +# v0.118.0) ship NO consumer rules at all — nobody upstream has reasoned about R8 +# for these modules, because Termux itself ships unminified. +# 2. :terminal-view deliberately binds to NON-API internals of the pinned version +# (plan §6.1 — TerminalView is `public final`, so we fork rather than subclass): +# · RemoteTerminalHostView writes and reads the public field +# `com.termux.view.TerminalView.mEmulator` directly (installEmulator / +# the TerminalViewClient callbacks / computeVerticalScrollRange). +# · TerminalResizeDriver calls `TerminalRenderer.getFontWidth()` and +# `getFontLineSpacing()` — public accessors verified with `javap -p`, but not +# part of any documented API surface. +# · TerminalScrollGesture reads `mEmulator` and drives KeyHandler. +# Field/method RENAMING alone would survive that (R8 rewrites both sides), but R8 +# full mode also does class merging, member inlining and access relaxation across +# a library whose invariants we are already stretching. A wrong outcome here is a +# crash in the app's single core surface — the terminal — visible ONLY in release. +# 3. The cost is bounded and small: two Apache-2.0 modules, ~120 KB of classes. +# +# So: keep them whole. Revisit only with a device-verified minified build in hand. +-keep class com.termux.terminal.** { *; } +-keep class com.termux.view.** { *; } + + +# ── ML Kit / Firebase component discovery (reflective no-arg constructors) ─────── +# +# OBSERVED, not speculative. Installing the minified `benchmark` APK on an API-35 +# emulator and cold-starting it produced, with no rule here: +# +# W ComponentDiscovery: Invalid component registrar. +# Could not instantiate com.google.mlkit.common.internal.CommonComponentRegistrar +# ... at com.google.mlkit.common.internal.MlKitInitProvider.onCreate +# Caused by: java.lang.NoSuchMethodException: +# com.google.mlkit.common.internal.CommonComponentRegistrar. [] +# (and the same for com.google.mlkit.vision.common.internal.VisionCommonRegistrar) +# +# Firebase's ComponentDiscovery instantiates registrars named in by +# `getDeclaredConstructor()`. The class names survive, but R8 removed the no-arg +# constructors because nothing in the app calls them. The failure is a WARNING, not a +# crash: the app starts fine and the ML Kit component graph is simply never registered, +# so **QR pairing (A19 barcode scanning) silently stops working in minified builds +# only**. Exactly the release-only breakage this file has to prevent. +# +# `();` (no access modifier) matches any visibility, which is what +# getDeclaredConstructor needs. Scoped to registrars, so it keeps ~a dozen tiny classes. +-keep class * implements com.google.firebase.components.ComponentRegistrar { + (); +} + + +# ── javax.naming does not exist on Android ────────────────────────────────────── +# +# R8 refuses to run without these two, so they are load-bearing for ANY minified build. +# They are NOT a benign suppression — read this before deleting them: +# +# :client-tls CertificateSummary.kt uses `javax.naming.ldap.LdapName` to pull the CN +# out of an RFC2253 DN. That package is JDK-only; it is absent from android.jar and +# from the ART bootclasspath. So on a real device `CertificateSummaryReader.commonName` +# throws NoClassDefFoundError, which is an Error and therefore slips straight through +# that function's `catch (_: Exception)` guard. +# +# The module is pure-JVM and its unit tests run on a real JDK where LdapName DOES +# exist, which is why the green JVM suite never caught it. Tracked as a handoff to +# :client-tls's owner (hand-parse the DN, or catch Throwable). This -dontwarn only +# stops R8 from failing the build over an unresolvable reference; it does not and +# cannot fix the device behaviour. +-dontwarn javax.naming.ldap.LdapName +-dontwarn javax.naming.ldap.Rdn + + +# ── Crash triage ──────────────────────────────────────────────────────────────── +# Keep line numbers so release stack traces are usable, and rename the source-file +# attribute to a single constant so no original filenames leak in the APK. Retrace +# release traces with app/build/outputs/mapping/release/mapping.txt (ARCHIVE IT with +# every distributed artifact — without it a crash report is unreadable). +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a179dfa..4d73b9d 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,5 +1,6 @@ ## Version catalog — single source of truth for plugin & dependency versions. -## Pure JVM modules only (this env has NO Android SDK). See android/README.md. +## Covers BOTH the pure Kotlin/JVM modules and the Android-framework modules (the +## SDK is installed on this machine — see android/README.md "Android SDK setup"). ## Kotlin pinned to 2.3.21 to match the Kotlin embedded in Gradle 9.6.1 (best ## compatibility with the Kotlin Gradle Plugin on Gradle 9.6). @@ -52,10 +53,18 @@ datastore = "1.1.1" # listenablefuture conflict dep is unnecessary here). termux = "v0.118.0" # androidx-test — lets androidTest (instrumented) sources COMPILE here; they only -# RUN on a device (no emulator installed → deferred to device QA, plan §7). +# RUN on a device/emulator (plan §7 — the A34 E2E suite needs a real `npm start` host). androidxTestExtJunit = "1.2.1" androidxTestCore = "1.6.1" androidxTestRunner = "1.6.2" +androidxTestRules = "1.6.1" +# Espresso + UiAutomator drive the A35 happy path (in-process Compose assertions and +# out-of-process device interaction respectively). Pinned to the androidx-test 1.6 line. +espresso = "3.6.1" +uiautomator = "2.3.0" +# Macrobenchmark (A35) — startup/frame timing measured from a SEPARATE `com.android.test` +# module against a non-debuggable, minified `benchmark` variant of :app. +benchmark = "1.3.4" # QR pairing (A19): CameraX preview/analysis + on-device ML Kit barcode scanning. camerax = "1.4.1" mlkitBarcode = "17.3.0" @@ -127,6 +136,20 @@ termux-terminal-emulator = { module = "com.github.termux.termux-app:terminal-emu androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExtJunit" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } +androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +# Compose UI test — BOM-managed (do NOT pin). `ui-test-manifest` is a debugImplementation: +# it supplies the empty activity `createComposeRule()` launches into. +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +# Hilt instrumented testing — supplies `HiltAndroidRule` + the generated +# `dagger.hilt.android.testing.HiltTestApplication` that :app's custom runner installs. +# REQUIRES kspAndroidTest(hilt-compiler); without the processor HiltTestApplication is +# never generated and the runner will not compile. +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +# Macrobenchmark (A35) — used by the :macrobenchmark `com.android.test` module only. +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" } # QR pairing camera (A19) androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } @@ -148,6 +171,17 @@ unit-test = ["junit-jupiter", "kotlinx-coroutines-test", "turbine", "mockk"] # CameraX (A19 QR pairing) — preview + analysis + PreviewView. camerax = ["androidx-camera-core", "androidx-camera-camera2", "androidx-camera-lifecycle", "androidx-camera-view"] +# Instrumented (androidTest) foundation — wired into :app's androidTestImplementation. +# JUnit4 arrives transitively via androidx.test.ext:junit (AndroidJUnitRunner is JUnit4-only; +# the JVM unit tests stay on the JUnit5 platform, which instrumented tasks never touch). +android-instrumented-test = [ + "androidx-test-ext-junit", + "androidx-test-core", + "androidx-test-runner", + "androidx-test-rules", + "androidx-test-espresso-core", +] + # Compose UI + Material 3 (+ Adaptive) — wired into :app implementation. All # BOM-managed; add ui-tooling separately as a debugImplementation. compose = [ @@ -167,6 +201,12 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" } +# NOTE: there is deliberately NO `android-test` alias for :macrobenchmark's +# `com.android.test` plugin. The two aliases above put AGP on the build classpath with +# an "unknown version", so a THIRD versioned alias for the same artifact is rejected +# ("plugin is already on the classpath with an unknown version"). :macrobenchmark +# therefore applies `id("com.android.test")` bare and inherits the `agp` version above — +# exactly the pattern :terminal-view uses for `id("com.android.library")`. kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } # NOTE: AGP 9+ has BUILT-IN Kotlin support — Android modules apply only the # android plugin, NOT a separate org.jetbrains.kotlin.android (it errors out). diff --git a/android/keystore.properties.example b/android/keystore.properties.example new file mode 100644 index 0000000..abcb3ec --- /dev/null +++ b/android/keystore.properties.example @@ -0,0 +1,31 @@ +# TEMPLATE ONLY — copy to `keystore.properties` and fill in. NEVER commit the copy. +# +# cp keystore.properties.example keystore.properties +# +# BEFORE you create the real file, make sure `keystore.properties` is listed in +# android/.gitignore. If it is not there yet, use `local.properties` instead — that +# path is ALREADY gitignored and :app reads the same four keys from it. +# +# Resolution order in app/build.gradle.kts (first non-blank wins): +# 1. android/keystore.properties +# 2. android/local.properties +# 3. WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI) +# +# With none of them set, debug + benchmark builds work normally and +# `:app:assembleRelease` FAILS at packaging with instructions — it never emits an +# unsigned APK. +# +# Generate a keystore OUTSIDE the repository: +# keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \ +# -alias webterm -keyalg RSA -keysize 4096 -validity 10000 +# +# Then read its SHA-256 (needed for the App Links assetlinks.json — plan §8): +# keytool -list -v -keystore ~/.webterm/webterm-release.jks -alias webterm | grep SHA256 + +# Path to the keystore. Absolute, or relative to the `android/` directory. +# Keep the keystore itself out of the repo (android/.gitignore blocks *.jks / *.keystore, +# but out-of-tree is the safer habit — a lost signing key cannot be recovered). +webterm.release.storeFile=/absolute/path/to/webterm-release.jks +webterm.release.storePassword=CHANGE_ME +webterm.release.keyAlias=webterm +webterm.release.keyPassword=CHANGE_ME diff --git a/android/macrobenchmark/build.gradle.kts b/android/macrobenchmark/build.gradle.kts new file mode 100644 index 0000000..7d0d732 --- /dev/null +++ b/android/macrobenchmark/build.gradle.kts @@ -0,0 +1,89 @@ +// ───────────────────────────────────────────────────────────────────────────── +// :macrobenchmark (A35) — the macrobenchmark harness module. +// +// `com.android.test`, NOT `com.android.library`: macrobenchmark must drive the app +// OUT of process (its own APK, its own process, UiAutomator across the boundary) so +// that measured startup/frame timings are the real ones and not perturbed by the +// test framework sharing the app's process. +// +// It instruments :app's `benchmark` variant — non-debuggable, R8'd and +// resource-shrunk like release, but signed with the debug key so no release keystore +// is needed (see app/build.gradle.kts → buildTypes.benchmark). +// +// This module currently has NO sources: the benchmark itself (pair → attach → type → +// approve, plus a cold-start timing) is the next task's deliverable and belongs in +// src/main/kotlin/. The module exists so that work is a matter of adding a file. +// +// Run (device/emulator required — `./gradlew :app:assembleDebug` style CI cannot): +// ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest +// +// AGP 9 has built-in Kotlin → apply ONLY the android plugin (no kotlin.android). +// The plugin id is applied BARE (no catalog alias / no version): the root build script +// already puts AGP on the classpath via the android.library + android.application +// aliases, and Gradle rejects a third versioned request for the same plugin artifact +// ("already on the classpath with an unknown version"). The version is still pinned in +// one place — `agp` in gradle/libs.versions.toml. Same pattern as :terminal-view. +// ───────────────────────────────────────────────────────────────────────────── + +plugins { + id("com.android.test") +} + +android { + namespace = "wang.yaojia.webterm.macrobenchmark" + compileSdk = 36 + + defaultConfig { + // Macrobenchmark itself needs API 23+; matching :app's 29 keeps the variant + // matrix honest (a benchmark on an SDK :app cannot run on is meaningless). + minSdk = 29 + targetSdk = 35 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + // The only variant this module is built in. `isDebuggable = true` applies to the + // TEST apk (never to :app) — required so the harness can be instrumented at all. + create("benchmark") { + isDebuggable = true + signingConfig = getByName("debug").signingConfig + // :app publishes debug/release/benchmark; libraries publish debug/release. + matchingFallbacks += listOf("release") + } + } + + targetProjectPath = ":app" + + // The harness runs in its OWN process and reaches the app only through UiAutomator / + // shell, never by linking against its classes. Without this, AGP's + // `checkTestedAppObfuscationBenchmark` refuses to build a non-minified test module + // against a minified app — a check that is correct for Espresso-style tests (which do + // link app symbols) and a false positive for macrobenchmark. + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +kotlin { + jvmToolchain(17) +} + +// Only the `benchmark` variant is meaningful; disable the stock debug/release ones so +// `./gradlew build` does not try to assemble a harness against a variant of :app that +// does not exist. +androidComponents { + beforeVariants { variant -> + variant.enable = variant.buildType == "benchmark" + } +} + +dependencies { + // A `com.android.test` module's `main` source set IS its test code — deps go in + // implementation, not androidTestImplementation. + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.uiautomator) + implementation(libs.androidx.benchmark.macro.junit4) +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 46ba5ca..e7b3182 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -51,3 +51,10 @@ include(":app") // A13 — libs.plugins.android.application + c include(":terminal-view") // A16 — android-library + Termux terminal-emulator/-view (JitPack, Apache-2.0) include(":host-registry") // A12 — android-library + DataStore (Host list / last-session) include(":client-tls-android") // A11 — android-library + AndroidKeyStore import + Tink AEAD + +// ── Instrumented-only module (no production code, never on :app's classpath) ───── +// :macrobenchmark (A35) — a `com.android.test` module that drives :app's `benchmark` +// variant OUT of process via UiAutomator. Separate APK by construction, so it cannot +// violate the "dependencies only flow down" rule: nothing depends on it, and it depends +// on :app only through `targetProjectPath`, not a project() dependency. +include(":macrobenchmark") // A35 — libs.plugins.android.test + benchmark-macro-junit4