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.
240 lines
17 KiB
Markdown
240 lines
17 KiB
Markdown
# Android client — device-QA checklist (A36 / plan §7)
|
||
|
||
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.<init> []`,
|
||
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`). Read it with
|
||
`keytool -list -v -keystore <ks> -alias <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://<lan-ip>: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://<anything>.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.
|
||
|
||
- [ ] `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.
|
||
- [ ] kill via `DELETE /live-sessions/:id` (swipe-to-kill) removes the row (404 = already-gone = success).
|
||
- [ ] `POST /hook/decision` resolves a held gate (from a push Allow/Deny).
|
||
- [ ] **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; 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.
|
||
- [ ] key-bar above the IME (soft keyboard never pops on a key-bar tap); hardware chords; DECCKM arrows
|
||
emit `ESC O A` under app-cursor mode (vim/htop).
|
||
- [ ] `FLAG_SECURE` + privacy cover blanks the recents thumbnail on `ON_STOP`.
|
||
- [ ] off-main chunked append does not ANR on a multi-MB replay.
|
||
|
||
## Push (A30/A31 · R1/S2)
|
||
- [ ] **S2 spike:** data-only high-priority delivery latency/loss on ≥2 real handsets (incl. one
|
||
Xiaomi/Huawei/Samsung) under Doze / OEM battery managers / force-stop — quantify loss, drive the
|
||
"delivery not guaranteed while backgrounded" user-facing copy.
|
||
- [ ] **Deny** from the lock screen (BroadcastReceiver, no app open, expedited POST).
|
||
- [ ] **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).
|
||
- [ ] 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. **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. (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).
|
||
|
||
## Projects / git parity (W5 — presenters JVM-tested, Compose device-QA)
|
||
- [ ] Project card **sync chip**: `↑ahead` / `↓behind` render only when non-zero; no chip when there is
|
||
no upstream (fields absent).
|
||
- [ ] Project detail **PR chip**: `availability=ok` → tappable chip opens the PR in the browser ONLY when
|
||
the url is https (a non-https / junk url is inert, non-clickable); `no-pr` / `not-installed` /
|
||
`unauthenticated` / `disabled` / `error` each render the degraded copy inertly; check-count colour
|
||
(fail=red / pending=amber / pass=green).
|
||
- [ ] Project detail **recent commits**: list renders short-hash + subject inertly; unavailable state on a
|
||
log failure does NOT hide the rest of the detail (failure-isolated).
|
||
- [ ] **New worktree** inline form: valid `branch` (+optional `base`) → create → list refreshes; an invalid
|
||
branch name is rejected with NO network call; a disabled-403 shows the server's safe message.
|
||
- [ ] Per-worktree **remove**: the button is absent on the `main` worktree; the confirm dialog offers a
|
||
**Force** checkbox; a dirty-worktree 409 surfaces "force required" inertly; **prune** button works.
|
||
- [ ] Diff **base-rev** input: entering a rev enters base mode (Working/Staged toggle hidden, `vs <rev>`
|
||
shown, git-write controls hidden); Clear returns to working/staged; junk rev → server 400 surfaced.
|
||
- [ ] Diff **stage/unstage**: per-file button (Working→"暂存", Staged→"取消暂存") posts the file and
|
||
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 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).
|
||
- [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a
|
||
`dataExtractionRules` `<device-transfer>` 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.
|