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.
17 KiB
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 -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 crashempty; noFATAL EXCEPTION,NoClassDefFoundErrororClassNotFoundException. 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_NOTIFICATIONSruntime prompt appears on first launch (API 33+ path, A30).- ML Kit component registration no longer fails under R8 (
ComponentDiscoveryclean). Before theComponentRegistrarkeep rule it loggedNoSuchMethodException: 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:assembleReleasedeliberately FAILS at packaging until they exist — it will never emit an unsigned APK. Providewebterm.release.{storeFile,storePassword,keyAlias,keyPassword}inandroid/keystore.properties(seekeystore.properties.example; confirm it is gitignored first) orandroid/local.properties, or theWEBTERM_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 (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). Read it withkeytool -list -v -keystore <ks> -alias <alias> | grep SHA256. Server-side: runA33src/push/fcm.tswith theFCM_*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/RemoteTerminalHostViewIME contract. (Was: NPE on the first keypress — stockTerminalView.onKeyDowndereferencesmClient, thenmTermSession.) - 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→mTermSessionNPE.) - 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.) resizeactually reaches the PTY. A full-screen TUI fills the screen instead of rendering into an 80x24 box, and reflows on rotation. (Was:updateSizehad zero call sites.) Confirm the cols×rows the server reports match whatTerminalResizeDrivercomputed fromTerminalRenderer.getFontWidth()/getFontLineSpacing().- Bare-LAN
ws://connects. Pair withhttp://<lan-ip>:3000and attach. (Was:network_security_config.xmlwas a stub denying all cleartext whileHostEndpointderivesws://fromhttp://.) - The tunnel domain is still TLS-only.
http://<anything>.terminal.yaojia.wangmust be refused (the one hostname withcleartextTrafficPermitted="false"), andOkHttpClientFactorymust 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_TOKENgate 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 inlogcat. ⚠️ 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
queueframe ("N queued" badge) andstatus.previewwere 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 → 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
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 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).
- 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
ComponentRegistrarnote 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
.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. - Cert summary does not crash — see the
javax.namingdefect under "Known gaps"; the issuer-CN / expiry summary is expected to throwNoClassDefFoundErroron-device TODAY. - DataStore host list /
LastSessionStoreset-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 andassetlinks.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/↓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 gaps (tracked, non-blocking for QA but must be routed to an owner)
:client-tlsusesjavax.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 throwsNoClassDefFoundError, anError— so it slips straight through the function'scatch (_: Exception)guard and propagates out ofsummarize(). The module is pure-JVM and its unit tests run on a JDK whereLdapNameDOES 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 catchThrowable). The-dontwarninproguard-rules.proonly unblocks R8; it changes nothing at runtime.- The auth cookie jar + cipher are not installed in
:app's DI, so theWEBTERM_TOKENgate 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
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). - Device-to-device transfer is not covered by
allowBackup=false; it needs adataExtractionRules<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 nullonThumbnailPipeline.ktwhen run under--rerun-tasks.lintVitalReleasepasses 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-tasksin a release gate.
RESOLVED since the last revision: the A21 reflection shim (
getMethod("getTerminalView")) is gone —RemoteTerminalHostViewnow holds the stockcom.termux.view.TerminalViewdirectly, so there is no reflective getter left to break under R8 and no keep rule is needed for one.