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.
9.0 KiB
9.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 (PushCoordinatorguards its absence withrunCatching, so the app runs without it; push just won't register).google-servicesGradle plugin re-enabled oncegoogle-services.jsonexists (A13 deliberately omitted it — applying it without the json fails the build).https://terminal.yaojia.wang/.well-known/assetlinks.jsonwith the release signing-cert SHA-256 (for the verified App LinkautoVerify). Server-side: runA33src/push/fcm.tswith theFCM_*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 → outputround-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/decisionresolves 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/mFontLineSpacingare package-private → measured on-device, fed to the JVM-testedTerminalGridMath); 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 Aunder app-cursor mode (vim/htop). FLAG_SECURE+ privacy cover blanks the recents thumbnail onON_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
excludeFromRecentstrampoline hostsBiometricPrompt→ 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_NOTIFICATIONSruntime 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
.p12import → 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-readingX509KeyManager+connectionPool.evictAll()); remove. - DataStore host list /
LastSessionStoreset-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.
TinkAccessTokenStoreinstrumented 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
/authlimiter). - Pair a host with
WEBTERM_TOKENunset 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_TOKENon 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 withadb logcat/ the server log: exactly one upgrade attempt). adb logcatduring 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/↓behindrender 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/erroreach 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(+optionalbase) → 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
mainworktree; 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
openAppIntentdoesn't carry the sessionId; the gate is still visible in the terminal). MEDIUM. - no "view diff" affordance from ProjectDetail → the
DiffScreennav destination has no inbound link. - no host-remove UI action (so
PushRegistrar.unregisterHostisn't invoked). - A21 hosts the Termux
TerminalViewvia a reflection getter (works;com.termux.view.TerminalViewisimplementation-scoped in:terminal-view) — clean fix = addlibs.termux.terminal.viewto:appand delete the shim.