# Android client — device-QA checklist (A36 / plan §7) Everything below is **device/emulator-only**: it cannot be proven by the JVM suite. The pure logic underneath each item IS unit-tested (757 JVM tests across 10 modules, Kover ≥80% on the pure modules), but the 2026-07-30 audit established the hard lesson this file now exists to enforce — **a green JVM suite says nothing about whether the app works.** Three crash-on-first-keypress defects and a cleartext posture that made bare-LAN connection impossible all sat behind 484 passing tests, because no JVM test instantiates an Android `View` and no JVM test dials a socket. **Rules for this file:** tick a box ONLY after observing the behaviour on real hardware (or, where noted, an emulator) — and say what you observed. Never tick something because the code looks right or a unit test covers it. Unticked is the honest default. --- ## Observed so far (the only on-device evidence that exists) **2026-07-30, minified `benchmark` variant, `webterm` AVD (android-35, arm64, software GPU):** - [x] The **R8-minified, resource-shrunk, non-debuggable APK installs and cold-starts.** `am start -W` → `Status: ok`, `LaunchState: COLD`, `TotalTime: 1085 ms` (software-GPU emulator — not a meaningful performance number, only proof of a clean start). Process stayed alive; `logcat -b crash` empty; no `FATAL EXCEPTION`, `NoClassDefFoundError` or `ClassNotFoundException`. This is the check that R8 keep rules are correct — the class of bug that appears ONLY in release. - [x] **Cold-start route with no paired host → pairing screen**, rendered correctly under R8 (`ColdStartPolicy`, A29): title 配对主机, the 手动输入/扫码 segmented control, the `主机地址(http(s)://…)` field and a disabled 下一步 button. Compose + Material 3 + the design-token theme + Hilt injection therefore all survive minification. - [x] **`POST_NOTIFICATIONS` runtime prompt appears** on first launch (API 33+ path, A30). - [x] **ML Kit component registration** no longer fails under R8 (`ComponentDiscovery` clean). Before the `ComponentRegistrar` keep rule it logged `NoSuchMethodException: com.google.mlkit.common.internal.CommonComponentRegistrar. []`, which would have silently disabled **QR barcode scanning in release builds only**. **Expected/benign in the same run:** `W FirebaseApp: Default FirebaseApp failed to initialize because no default options were found` — there is no `google-services.json` yet (see the deploy artifacts below). A `System UI isn't responding` dialog also appeared; that is the emulator's own `com.android.systemui` under software GPU, not this app (our process logged no ANR and stayed `topResumedActivity`). **Everything else in this file is unobserved.** Notably: no real WebTerm host has ever been connected to from Android, no terminal bytes have ever been rendered on a device, and no push has ever arrived. --- ## Deploy artifacts to provide first (not built here) - [ ] **Release signing credentials.** `:app:assembleRelease` deliberately FAILS at packaging until they exist — it will never emit an unsigned APK. Provide `webterm.release.{storeFile,storePassword,keyAlias,keyPassword}` in `android/keystore.properties` (see `keystore.properties.example`; confirm it is gitignored first) or `android/local.properties`, or the `WEBTERM_RELEASE_*` env vars. Keep the keystore out of the repo — a lost signing key cannot be recovered. - [ ] `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`). Read it with `keytool -list -v -keystore -alias | grep SHA256`. Server-side: run `A33` `src/push/fcm.ts` with the `FCM_*` env group (service-account key path + project id). --- ## FIRST PRIORITY — confirm the 2026-07-30 blocker fixes on a device These were the defects that made the app unusable on hardware. All are fixed and JVM-verified; **none has been exercised on a device.** Until this block is ticked, nothing further in this file is worth running, because every one of these sits on the path to the terminal. - [ ] **Typing does not crash.** Soft-keyboard keys, Enter, Backspace via the installed `WebTermTerminalViewClient` / `RemoteTerminalHostView` IME contract. (Was: NPE on the first keypress — stock `TerminalView.onKeyDown` dereferences `mClient`, then `mTermSession`.) - [ ] **Swipe-scrolling inside an alternate-screen TUI does not crash.** Claude Code IS an alternate-screen TUI, so this is ordinary use, not an edge case: drag vertically while a Claude session is running. Also test the fling and, on a device with a mouse/trackpad, wheel scroll (`onGenericMotionEvent`). (Was: `doScroll` → `handleKeyCode` → `mTermSession` NPE.) - [ ] **A password manager does not crash the app.** Open the app with an autofill service enabled and focus the terminal. (Was: unguarded `autofill()` while the view advertised itself autofillable; the subtree is now excluded from the autofill structure.) - [ ] **`resize` actually reaches the PTY.** A full-screen TUI fills the screen instead of rendering into an 80x24 box, and reflows on rotation. (Was: `updateSize` had zero call sites.) Confirm the cols×rows the server reports match what `TerminalResizeDriver` computed from `TerminalRenderer.getFontWidth()/getFontLineSpacing()`. - [ ] **Bare-LAN `ws://` connects.** Pair with `http://:3000` and attach. (Was: `network_security_config.xml` was a stub denying all cleartext while `HostEndpoint` derives `ws://` from `http://`.) - [ ] **The tunnel domain is still TLS-only.** `http://.terminal.yaojia.wang` must be refused (the one hostname with `cleartextTrafficPermitted="false"`), and `OkHttpClientFactory` must refuse a plaintext dial to a non-private host even if the config would allow it. - [ ] **§6.4 device-switch reclaim.** Attach the same session from a phone and a tablet; the device you last touched drives the PTY size (latest-writer-wins) and reclaims full screen on pane-show/window-focus without a re-attach. - [ ] **`WEBTERM_TOKEN` gate end-to-end** against a server started WITH the token: REST + the WS upgrade both carry the cookie, it survives a process restart (sealed with Tink AEAD under an AndroidKeyStore key), and a **seal failure persists nothing** rather than falling back to plaintext. Also verify the cookie never appears in `logcat`. ⚠️ **Blocked until the cookie jar + cipher are installed in `:app`'s DI** — until then the gate is inert at runtime no matter what the server does. - [ ] **Newly-wired surfaces that had ZERO call sites before this session** — each needs a first-ever look: session-list **preview thumbnails** (`ThumbnailPipeline`), **quick-reply chips + palette** (`QuickReply`), and the tablet **pointer context menu** (`PointerContextMenu`). - [ ] **Newly-decoding server frames.** The `queue` frame ("N queued" badge) and `status.preview` were both being dropped as undecodable — the latter meant remote approvals were **blind**. Confirm a real gate now shows the command being approved, and that a malformed preview does not cost the whole frame (the gate must still appear). --- ## KNOWN FEATURE GAP — copy-out via text selection is unavailable (deliberate) **Not a bug to be fixed by re-enabling stock selection.** At the pinned Termux v0.118.0, the stock `ActionMode`'s own handlers dereference the `TerminalSession` this app deliberately does not have (`TextSelectionCursorController$1.onActionItemClicked`, offsets 89-100 for Copy and 126-136 for Paste). `TerminalSession` is `final` and its constructor forks a local process over JNI — exactly what a remote terminal must not do (plan §6.1). So making the selection UI reachable would put a guaranteed NPE on the **Copy** button, which is worse than not offering it. Long press is therefore consumed and selection never starts. **The real fix** (not built, tracked): a first-party selection layer — hit-test to a cell, own the selection anchors, read the text straight out of `TerminalBuffer`, and write it with `ClipboardManager`. Paste-in is unaffected and already works. - [ ] Confirm on a device that long press is inert and does **not** show a broken Copy affordance. --- ## A34 — instrumented E2E vs a real `npm start` host Infrastructure now EXISTS (`:app` has androidTest deps, a Hilt-aware `testInstrumentationRunner`, and Espresso + Compose-UI-test on the classpath) but **the tests themselves are not written**. See `android/README.md` → "Instrumented tests" for the one runner class that must be authored first. - [ ] `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 The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumenting `:app`'s `benchmark` variant out-of-process); it has **no sources yet**. - [ ] pair → attach → type → approve, on a real device. - [ ] cold-start timing on real hardware (`StartupTimingMetric`). The 1085 ms above is a software-GPU emulator number and must not be quoted as a baseline. ## 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; http/https link tap. (Selection→clipboard is the gap above, not a test.) - [ ] **real font-metric → grid resize** and 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). - [ ] token registers to every paired host + self-heals on rotation / new host / removal. - [ ] **In a MINIFIED build specifically** — FCM and ML Kit both discover components reflectively, so re-verify push registration and QR scanning on the `benchmark`/release variant, not just debug. ## Pairing / cert / storage (A19/A27/A11/A12) - [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. **Run this on a minified variant too** (see the `ComponentRegistrar` note above — this path is the one R8 already broke once). - [ ] §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. - [ ] **Cert summary does not crash** — see the `javax.naming` defect under "Known gaps"; the issuer-CN / expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY. - [ ] 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. (The App Link half needs the release-signed APK and `assetlinks.json`.) - [ ] continue-last-session banner re-opens. (The no-host → pairing half is observed above.) - [ ] 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 gaps (tracked, non-blocking for QA but must be routed to an owner) - [ ] **`:client-tls` uses `javax.naming.ldap.LdapName`, which does not exist on Android.** Found by R8 (`Missing class javax.naming.ldap.LdapName ... referenced from CertificateSummaryReader.commonName`). On-device that call throws `NoClassDefFoundError`, an **`Error`** — so it slips straight through the function's `catch (_: Exception)` guard and propagates out of `summarize()`. The module is pure-JVM and its unit tests run on a JDK where `LdapName` DOES exist, which is exactly why the green suite never caught it. Effect: the device-cert screen's issuer-CN/expiry summary (A27) crashes. Fix: hand-parse the RFC2253 DN (or at minimum catch `Throwable`). The `-dontwarn` in `proguard-rules.pro` only unblocks R8; it changes nothing at runtime. - [ ] **The auth cookie jar + cipher are not installed in `:app`'s DI**, so the `WEBTERM_TOKEN` gate is inert at runtime even though every piece is implemented and tested. - [ ] 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). - [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a `dataExtractionRules` `` xml resource. Until then a D2D migration could copy the sealed auth cookie to another handset. - [ ] AGP's lint crashes with `FirDeclaration was not found for class KtProperty, fir is null` on `ThumbnailPipeline.kt` when run under `--rerun-tasks`. `lintVitalRelease` passes normally and from a cold lint state; this is a lint/K2 internal bug (lint says so itself), not an app defect. Do not use `--rerun-tasks` in a release gate. > RESOLVED since the last revision: the A21 reflection shim (`getMethod("getTerminalView")`) is **gone** — > `RemoteTerminalHostView` now holds the stock `com.termux.view.TerminalView` directly, so there is no > reflective getter left to break under R8 and no keep rule is needed for one.