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.
120 lines
9.0 KiB
Markdown
120 lines
9.0 KiB
Markdown
# 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=<16–512 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.
|