Files
web-terminal/android/DEVICE_QA_CHECKLIST.md
Yaojia Wang bc31de85dd feat(android): Projects/Diff/Worktree/git-write parity with web+iOS (W5)
Brings the native Android client to parity with the web/iOS Projects + git surface,
consuming the existing server endpoints — ZERO server change. (The "SDK-gated modules"
premise was stale; they were already online.)

- :api-client (pure Kotlin/JVM): PrStatus/GitLog/GitWrite models (tolerant decoders,
  safe-error), ProjectInfo + ahead/behind/lastCommitMs; 8 new Endpoints builders
  (projectPr/projectLog read-only; createWorktree/removeWorktree/prune/gitStage/
  gitCommit/gitPush guarded w/ Origin) + ApiClient methods (PR/log degrade in body;
  writes 200→Ok / 429→RateLimited / 4xx-5xx→Rejected(safe msg)).
- :app presenters (JVM-tested): DiffViewModel base-compare + git stage/commit/push;
  new WorktreeViewModel (create/remove/prune + client branch validation + isMain block);
  ProjectDetailViewModel failure-isolated PR-chip + recent-commits.
- Compose screens wired: ProjectDetail (PR chip tappable only for https, recent commits,
  worktree create/remove-with-force/prune, view-diff), Diff (base input, per-file
  stage/unstage, commit/push bar), Projects (ahead/behind sync chip). Nav closes the
  pre-existing "no inbound link to the diff screen" gap.

Verified independently: `./gradlew :app:assembleDebug test :api-client:koverVerify`
BUILD SUCCESSFUL; 348 unit tests pass (api-client 102 / app 246); coverage gate held;
git status android-only. Compose rendering/interaction deferred to on-device QA
(android/DEVICE_QA_CHECKLIST.md), as prior Android waves did.
2026-07-13 05:58:28 +02:00

7.0 KiB

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