Files
web-terminal/android/DEVICE_QA_CHECKLIST.md
Yaojia Wang 97e39949a8 Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
2026-07-30 18:12:03 +02:00

264 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
## Access token — `WEBTERM_TOKEN` (B5 / ios-completion §1.1) — needs a token-enabled host
Run the host as `WEBTERM_TOKEN=<16512 chars> npm start`. The pure logic is JVM-tested (`AuthProbeTest`,
`AccessTokenCookieTest`, `PairingProbeTokenTest`, `OkHttpAccessTokenTest`, `SessionEngineUnauthorizedTest`,
`InMemoryAccessTokenStoreTest`); everything below needs real hardware + a real server.
- [ ] `TinkAccessTokenStore` instrumented suite on a device:
`./gradlew :host-registry:connectedDebugAndroidTest` (real AndroidKeyStore + Tink).
- [ ] Pair a token-enabled host with the token typed → paired; the terminal attaches (the **WS upgrade**
carries `Cookie: webterm_auth=…`), the session list, projects, diff and thumbnails all load.
- [ ] Pair the same host with **no** token typed → the probe 401s → the confirm step comes back asking for
the token ("该主机启用了访问令牌"); typing it and retrying pairs.
- [ ] Wrong token → "访问令牌不正确" **under the field** (still on the confirm step, nothing stored).
- [ ] 11 wrong tries within a minute → "尝试过多" (the server's 10/min/IP `/auth` limiter).
- [ ] Pair a host with `WEBTERM_TOKEN` **unset** while typing a token → pairs, and NOTHING is stored
(204-without-`Set-Cookie`); the app must not claim to be "authenticated".
- [ ] Restart the app → the terminal re-attaches with no re-prompt (the token decrypts from the
Keystore-backed store on a cold start).
- [ ] Rotate `WEBTERM_TOKEN` on the host and restart it → the WS upgrade 401s → the terminal banner shows
the 401 copy, offers **no** "新会话", and there is **no reconnect back-off storm** (verify with
`adb logcat` / the server log: exactly one upgrade attempt).
- [ ] `adb logcat` during all of the above: the token string appears **nowhere**; no request URL contains
it (check the server's access log too).
- [ ] `adb backup` / device-to-device transfer excludes the token blob (`allowBackup=false` +
`data_extraction_rules.xml`).
## 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.