Files
web-terminal/android/DEVICE_QA_CHECKLIST.md
Yaojia Wang 9114630c3a feat(android): access-token support — same frozen contract as iOS
Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
2026-07-30 12:45:27 +02:00

120 lines
9.0 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 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.
## Deploy artifacts to provide first (not built here)
- [ ] `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).
## 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.
- [ ] 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
- [ ] pair → attach → type → approve, on a real device.
## 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).
- [ ] **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).
- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals
on rotation / new host / removal.
## Pairing / cert / storage (A19/A27/A11/A12)
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry.
- [ ] §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.
- [ ] 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.
- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens.
- [ ] 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 minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
- [ ] 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.