# 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 ` 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.