Files
web-terminal/android/DEVICE_QA_CHECKLIST.md
Yaojia Wang c1612d0145 build(android): make release buildable and instrumented testing possible
Four gaps that made "ship it" and "test it on hardware" impossible.

RELEASE SIGNING. assembleRelease was silently producing
app-release-unsigned.apk because there was no signingConfigs block at all —
the failure mode where you only notice at install time. Credentials now
resolve from keystore.properties, then local.properties, then
WEBTERM_RELEASE_* env; nothing is created or committed, and
keystore.properties.example is the template.

The degradation is ENFORCED, not documented. The signingConfig is only
created when credentials actually resolve — an empty config is precisely what
makes AGP emit an unsigned artifact quietly — and a guard on packageRelease
throws with an actionable message, distinguishing "not configured" from
"credentials found but the keystore file is missing: <path>". It hooks the
PACKAGE task on purpose: R8 has already run and reported by then, so a
developer without a keystore still gets the full shrink result rather than an
early abort. Verified: minifyReleaseWithR8 completes, packageRelease fails
loudly, and no unsigned APK is written.

VERSIONING. Was still the scaffold 1 / "0.1.0". Now 1 / "0.1.0-alpha01",
where the suffix is a factual claim about device verification rather than
decoration: alpha means device QA is essentially unstarted, beta means the
A34/A35 checklist blocks pass on hardware, and plain 0.1.0 means the whole
checklist is ticked. versionCode stays 1 because no artifact has ever left
the build machine.

MINIFICATION. release had isMinifyEnabled = false and an empty rules file —
so nothing had ever exercised R8 on this app, and the first release build
would have been the experiment. Now minify + resource shrinking, with a
deliberately short rules file: the actual artifacts were checked, and
kotlinx.serialization, Tink, Hilt, FCM, OkHttp, Compose and CameraX all ship
their own consumer rules, so none are re-declared. The termux packages are
kept whole because the JitPack artifacts ship no rules and :terminal-view
binds to non-API internals — it writes the public TerminalView.mEmulator
field and calls TerminalRenderer.getFontWidth()/getFontLineSpacing(). Getting
that wrong yields a build that crashes only in production.
lintVitalRelease also passes now, the two pre-existing lint ERRORS having
been fixed earlier this session.

ANDROID TEST ENABLEMENT. :app had no androidTest source set and no
instrumentation runner, which is the mechanical reason A34/A35 have no code
at all. The runner (Hilt-aware) and dependencies are wired, and a
:macrobenchmark module exists. The tests themselves are a separate change.

Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:app:assembleBenchmark :macrobenchmark:assembleBenchmark
:app:lintVitalRelease koverVerify -> BUILD SUCCESSFUL; 901 JVM tests, 0
failures on a forced re-execution with test-results deleted and
--no-build-cache.
2026-07-30 12:14:53 +02:00

17 KiB
Raw Blame History

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):

  • The R8-minified, resource-shrunk, non-debuggable APK installs and cold-starts. am start -WStatus: 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.
  • 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.
  • POST_NOTIFICATIONS runtime prompt appears on first launch (API 33+ path, A30).
  • 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.<init> [], 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 <ks> -alias <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: doScrollhandleKeyCodemTermSession 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://<lan-ip>: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://<anything>.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.
  • 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 <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 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 <device-transfer> 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 goneRemoteTerminalHostView 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.