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

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