Merge android-blocker-fixes: make the Android client work on a device

The client reported COMPLETE with 617 green tests and a buildable APK, but had
never run on hardware, and the first interaction on a device crashed it. Seven
real defects, four of them crashes:

  - every key press NPE'd (no TerminalViewClient was ever installed)
  - the PTY never learned the real grid (no resize was ever sent)
  - bare-LAN ws:// could not connect (a STUB network-security-config)
  - swipe-scrolling inside an alternate-screen TUI NPE'd, i.e. during ordinary
    use of Claude Code, which is one
  - any password manager crashed the app (unguarded autofill)
  - onDraw tore the terminal buffer and killed the process — the defect the docs
    had explicitly classified as 'Accepted (not a defect)', and which was dormant
    only because resize never happened
  - text selection's Copy button was an NPE, so copy-out was cut rather than
    shipped broken

Also: the queue frame and status.preview were silently dropped (remote approvals
were blind), the v0.6 git panel was unrendered, ThumbnailPipeline/QuickReply/
PointerContextMenu were tested dead code with no call sites, the WEBTERM_TOKEN
gate was inert, release builds emitted unsigned APKs, and A34/A35 had no code at
all despite being marked done.

Then the audit's own leftovers: silent certificate rotation (which surfaced a bug
that would have failed every renewal, plus a latent iOS defect Android now
avoids), /device/:id/recover, the W2 inject-queue routes, unread-watermark
persistence, host removal, and /config/ui finally honoured fail-closed.

617 -> 1150 JVM tests, plus 67 instrumented tests that actually execute on an
emulator against this repo's own server. The device crashes are reproduced as
JVM tests where possible, so they are guarded without hardware.

Still open and recorded, not papered over: S2 needs two physical handsets under
Doze; the rest of DEVICE_QA_CHECKLIST has no automated cover.

# Conflicts:
#	docs/PROGRESS_LOG.md
This commit is contained in:
Yaojia Wang
2026-07-30 16:09:22 +02:00
187 changed files with 24095 additions and 449 deletions

View File

@@ -74,7 +74,7 @@ Reach functional parity with the iOS client's shipped feature set (P0 + P1). Eve
- **`DELETE /live-sessions` kill-all / a "manage" page** — **iOS does NOT consume this route**; its swipe-to-kill and pointer-menu kill both use per-session `DELETE /live-sessions/:id`. Out of parity; do not build a kill-all surface iOS lacks.
- **On-device WebView/xterm.js fidelity fallback** — only if Termux emulation fails QA (§6.8). Not built by default.
- **System-`KeyChain` MDM cert path** — documented fallback only; wrong trust model for a per-device pinned identity.
- **Self-signed-LAN `wss://` custom trust / pinning** — bare LAN uses `ws://` cleartext (allowlisted CIDRs); `wss` is only for the tunnel/Tailscale, which present real CA-signed certs. A `CertificatePinner`/custom `X509TrustManager` path is documented but not built (§6.9, §8).
- **Self-signed-LAN `wss://` custom trust / pinning** — bare LAN uses `ws://` cleartext (permitted in `base-config`; see §6.9 — the platform has no CIDR syntax, so it CANNOT be scoped to RFC1918); `wss` is only for the tunnel/Tailscale, which present real CA-signed certs. A `CertificatePinner`/custom `X509TrustManager` path is documented but not built (§6.9, §8).
- **Cross-device palette sync** — matches current design (iOS/web/Android stores are independent).
- **Voice commands / editor-open / worktree-create** — server has routes (`/open-in-editor`, `/projects/worktree`) the iOS client does not consume; out of parity scope.
@@ -96,7 +96,7 @@ Reach functional parity with the iOS client's shipped feature set (P0 + P1). Eve
| Persistence (secret / device cert) | **Google Tink AEAD + AndroidKeystore master key**; **the private key is imported non-exportable into `AndroidKeyStore`** (the runtime home). Tink AEAD encrypts only the **cert chain + metadata** blob at rest. **No `.p12` blob or passphrase is persisted.** | Non-exportable, device-bound = the `…AfterFirstUnlockThisDeviceOnly` analogue. **Not `EncryptedFile`/`security-crypto`** (that Jetpack Security API is deprecated — draft inconsistency resolved). |
| Push | **Firebase Cloud Messaging (FCM) v1**, data-only high-priority | Only way to reproduce the lock-screen two-tap Allow/Deny loop (§4.5, §9 R1). |
| mTLS | **`AndroidKeyStore`-imported non-exportable key + a custom re-reading `X509KeyManager`** (returns the AndroidKeyStore `PrivateKey`/chain per handshake) → `SSLContext` → OkHttp `sslSocketFactory`. **`KeyStore("PKCS12")` is used transiently only to *parse the import file*, never as the runtime key home.** | App-private identity (NOT system `KeyChain`); re-reading KeyManager + `connectionPool.evictAll()` gives the iOS "no-relaunch cert rotation" behavior. One key home, no contradiction (R4/mustFix). |
| Server-cert trust | **Default system trust** (tunnel `*.terminal.yaojia.wang` + Tailscale MagicDNS present real LE certs). Bare LAN uses **`ws://` cleartext** via a `network_security_config` CIDR allowlist. | Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. |
| Server-cert trust | **Default system trust** (tunnel `*.terminal.yaojia.wang` + Tailscale MagicDNS present real LE certs). Bare LAN uses **`ws://` cleartext**, permitted in `network_security_config`'s `base-config` — the format has NO address-range syntax, so it cannot be narrowed to LAN literals (§6.9). | Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. |
| QR scan | **CameraX + ML Kit Barcode** (`com.google.mlkit:barcode-scanning`) | On-device, one validator shared with manual entry. |
| Biometric | **`androidx.biometric:BiometricPrompt`** hosted in a `FragmentActivity` (the Allow trampoline) | Gates the Allow action. |
| Testing | **JUnit5 + kotlinx-coroutines-test (virtual time) + Turbine + MockK** (unit); **Espresso + Compose UI Test** (instrumented; Robolectric only where it faithfully emulates); **Kover** (coverage) | Mirrors iOS's injected-clock/fake-transport discipline. `AndroidKeyStore`/Tink tests are **instrumented on a real emulator/device** — Robolectric does not provide the AndroidKeyStore provider (platform review). |
@@ -429,7 +429,20 @@ Two tiers, chosen by decision gate. **Note the blast radius:** the tier-2 WebVie
OkHttp requires an `(SSLSocketFactory, X509TrustManager)` pair. Decision:
- **Tunnel (`*.terminal.yaojia.wang`) + Tailscale MagicDNS:** real CA-signed (LE) certs → **default system trust**. No custom trust manager.
- **Bare LAN:** use **`ws://` cleartext**, permitted only for private LAN/Tailscale CIDRs via `network_security_config` (global `usesCleartextTraffic=false`).
- **Bare LAN:** use **`ws://` cleartext**, permitted in `network_security_config`'s `base-config`.
**CORRECTED (was wrong in this plan until the A19 fix):** the original text said cleartext would be
"permitted only for private LAN/Tailscale CIDRs via `network_security_config`". That is not
expressible. The network-security-config format has NO netmask/prefix/CIDR attribute anywhere — a
`<domain>` holds one hostname or one IP literal, and `includeSubdomains` walks DNS labels, so it cannot
generalise `192.168.0.0/16`. Enumerating ~18.9M addresses is not an option either. Since a LAN address
is user-typed at runtime and this file is a build-time resource, it also cannot be scoped to "the IP the
user actually entered". So: cleartext is ON in `base-config`, `*.terminal.yaojia.wang` keeps an explicit
`cleartextTrafficPermitted="false"` block (the one hostname known at build time), trust anchors are
pinned to `system` only, and the real guard moves into the app — the §5.4 warning tiers plus
confirm-before-network. The scoping the platform cannot express is additionally enforced at
transport time in `OkHttpClientFactory`, which refuses a plaintext dial to a non-private host.
Honest tradeoff: on bare `ws://` the terminal bytes and the `WEBTERM_TOKEN` cookie are readable and
replayable by anyone on the path. This is a LAN/Tailscale posture, never an internet-exposure one.
- **Self-signed LAN `wss`:** DEFERRED — a documented `CertificatePinner`/custom `X509TrustManager` path, not built by default.
---
@@ -468,7 +481,7 @@ Gesture/IME/CJK composition, camera-QR, haptics, lock-screen Allow/Deny, FCM end
- [ ] **mTLS trust model (one key home)** — private key **imported non-exportable into `AndroidKeyStore`**; a **custom re-reading `X509KeyManager`** returns the AndroidKeyStore `PrivateKey`/chain per handshake; `KeyStore("PKCS12")` is import-parse-only (transient), never the runtime home; **NOT** system `KeyChain`. Client cert presented on both WS and REST via one shared `OkHttpClient`. On rotation, `connectionPool.evictAll()` so pooled/resumed connections drop the old identity.
- [ ] **Cert storage** — private key non-exportable/device-bound; only the **cert chain + metadata** blob is stored, encrypted with **Tink AEAD + AndroidKeystore master key**; app-private, uninstall-wiped, never in backups/cloud. **No `.p12` blob or passphrase persisted.** Import validates before persisting so a bad passphrase can't clobber a prior identity.
- [ ] **No system-wide credential leakage** — no ephemeral/disk HTTP cache (`OkHttpClient.cache(null)`) — preview/diff bodies can contain terminal secrets.
- [ ] **Cleartext posture**`usesCleartextTraffic=false` globally; a `network_security_config` allowlist permits `ws://` only for LAN/Tailscale CIDRs; `wss` (tunnel/Tailscale) uses default system trust; §5.4 warning tiers reproduced in-app (public-host blocking needs explicit acknowledge; tunnel host = cert-gated, warning softened, TLS failure re-mapped to "client cert invalid/revoked").
- [x] **Cleartext posture**cleartext permitted in `base-config` (NO CIDR syntax exists — see §6.9), `*.terminal.yaojia.wang` explicitly denied, trust anchors `system`-only, no `debug-overrides`, and a transport-time private-host check in `OkHttpClientFactory`; locked by `NetworkSecurityConfigTest`; `wss` (tunnel/Tailscale) uses default system trust; §5.4 warning tiers reproduced in-app (public-host blocking needs explicit acknowledge; tunnel host = cert-gated, warning softened, TLS failure re-mapped to "client cert invalid/revoked").
- [ ] **Tunnel-host cert gate**`runProbe` refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed; both confirm and retry funnel through the one choke point (retry can't bypass).
- [ ] **Push token discipline** — single-use `/hook/decision` capability token validated as v4 UUID, passed only to the POST, **never persisted, never logged, never in fallback copy**; payload minimized (`sessionId`/`cls`/`token` only).
- [ ] **Notification-action trust split****Deny → `BroadcastReceiver`** (`goAsync()`, no UI, expedited POST). **Allow → a translucent, `excludeFromRecents` trampoline `Activity`** that hosts `BiometricPrompt` then POSTs — a `BroadcastReceiver`/`Service` cannot present `BiometricPrompt` and `goAsync()`'s ~10s budget would blow. (Corrects the draft's "no app open" claim for Allow.) All `PendingIntent`s `FLAG_IMMUTABLE` (API 31+). Deny is auth-free (fail-safe). The decision POST is **expedited** (not deferrable `WorkManager`); the single-use token makes a retried-after-success POST return 403 → idempotent retry-safety.

View File

@@ -24,6 +24,37 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### 🤖 [x] Android 客户端"能装但不能用"修复 — 7 个真实缺陷已修,设备上 67 个 instrumented 测试全绿(2026-07-30,worktree `android-blocker-fixes`)
- **起因**: 例行问"android 客户端完成状况如何"。`android/PROGRESS_ANDROID.md` 写着 **"✅ ANDROID CLIENT COMPLETE — all 36 plan tasks landed"**,617 个 JVM 测试全绿,APK 也出得来。**这个"完成"只在编译层面成立** —— 从来没在任何真机或模拟器上跑过,而一旦跑,第一个动作就崩。审计(45 agent,含逐条对抗验证)+ 修复(多波 agent)已把 blocker 清掉。
- **为什么 JVM 测试全绿也漏掉了**: 一个结构性原因 —— **没有任何 JVM 测试会实例化 Android `View`**`:terminal-view` 那 17 个测试测的是纯逻辑,渲染/输入/触摸整条路径无人触碰。
- **三个让 App 在真机上不可用的缺陷**(每一条都反编译 `terminal-view-v0.118.0.aar` 逐偏移确认):
1. **按任何键必崩**`RemoteTerminalView` 只绑了 emulator,从没调用 `setTerminalViewClient` —— KDoc 写着"A17 会装",A17 实际只交付了 `HardwareKeyRouter` 和 KeyBar(而 KeyBar 之所以能用,恰恰因为它绕过了 view)。stock `TerminalView``onKeyDown` offset 82、`onCreateInputConnection` offset 0、`onKeyUp``onKeyPreIme` 无判空解引用 `mClient`。**装个 client 还不够**:返回 false 会继续走到 offset 150 的 `mTermSession.write()`,而 `mTermSession` 永远是 null(`TerminalSession` 是 final,而 fork 进程正是本 App 绝不能做的事,§6.1)。修法:client 自己消费按键,且 `KeyRouting` **刻意没有"交还 stock"这个分支** —— 崩溃变成不可表达,而不只是被绕开。键表仍由 Termux `KeyHandler` 决定,DECCKM 照旧发 `ESC O A`
2. **resize 从来不发**`RemoteTerminalSession.updateSize` 零调用点,stock 兜底也是死的(`TerminalView.updateSize` 无 session 时 offset 18-25 直接 return),PTY 一直停在服务端的 80×24,所有全屏 TUI 都排版进错误的框。`TerminalResizeDriver` 现在从真实字距驱动 —— 字距走**公开** accessor `TerminalRenderer.getFontWidth()`/`getFontLineSpacing()`(agent 自己 `javap` 发现它们是 public),**不用反射**(R8 下会静默失效)也**不重测 Paint**(会与渲染器实际所用漂移,正是 §R5 风险)。
3. **局域网连不上**`network_security_config.xml` 是自己标着 STUB 的全局禁明文,而 `HostEndpoint` 接受 `http` 并派生 `ws://`。它注释里承诺的 CIDR 白名单**平台根本不支持**(NSC 格式没有任何 netmask/prefix 属性,`<domain>` 只装一个主机名或一个 IP 字面量)。改为 base-config 放开 + `*.terminal.yaojia.wang` 显式禁明文 + 信任锚钉死 system + `OkHttpClientFactory` 在传输层拒绝向非私有地址发起明文 —— 平台表达不了的收窄,放到 HTTP 客户端里做。plan §6.9/§8 的错误描述已一并更正。
- **对抗评审又挖出三个,其中一条评审自己判错了**:
- **在 alternate-screen TUI 里滑动滚屏仍会崩**。触摸路径完全绕过 `TerminalViewClient`:`doScroll` 在 alternate buffer 激活时把滚动转成 `handleKeyCode`,而那里解引用 `mTermSession`。**Claude Code 本身就是 alternate-screen TUI**,所以这是主界面正常使用中的崩溃。`TerminalScrollGesture` 抢占纵向拖动并复现 `doScroll` 的三个分支,连 fling runnable 和 `onGenericMotionEvent` 一并封死。
- **`autofill()` 无判空解引用 `mTermSession`**,而该 view 自称可自动填充 → 任何密码管理器一填就崩。整个子树已排除出 autofill 结构。
- 评审要求**恢复** stock 文字选择(此前被 focus 改造弄成不可达)。**这条它错了**:ActionMode 自己的 Copy/Paste 处理器也解引用空 session(`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136),恢复可达性只会把崩溃搬到用户点 Copy 那一刻 —— 比没有选择功能更糟。**长按保持被消费,复制功能如实记为功能缺口**(真正的修法是基于 `TerminalBuffer` + `ClipboardManager` 自建选择层)。
- **被静默丢弃的服务端数据,现已解码**: W2 `queue` 帧在 `ServerMessageType` 里没有成员,`decodeServer` 返回 null,`SessionEngine` 当"无法解码"丢掉 —— "N queued"徽标在 Android 上**从来不可能出现**;W1 `status.preview` 完全没建模,导致远程审批是**盲批**(只看到工具名,看不到要执行的命令/diff)。两者均已解码。一条规矩用测试钉住:**preview 坏掉绝不能连帧一起丢** —— `pending` 才是让 gate 出现的信号。
- **当作功能发布的死代码**: `ThumbnailPipeline`(会话列表预览)、`QuickReply`(芯片+调色板)、`PointerContextMenu` 三者实现完整、单测齐全、**生产代码零调用点**。均已接线。`ThumbnailPipeline` 当年被**明确跳过**的交叉评审(AW3 条目自己承认)这次补做了。
- **凭据处理**: `WEBTERM_TOKEN` cookie 现在在 REST 和 WS upgrade 上都携带,用 Tink AEAD + AndroidKeyStore 封装,**fail-closed**(封装失败就不持久化,绝不退回明文);`allowBackup=false` 阻止这个 30 天 shell 凭据经 Google Drive / D2D 外泄。
- **v0.6 git 面板数据层**: 服务端 8fe1f52 之后的字段 Android 全没解码。已补 `SyncState`/`dirtyCount`/`cwd`/`unpushed`/`upstream` + `GET /projects/worktree/state` + `POST /projects/git/fetch`。两条易错语义**编码成行为**而非靠调用点自觉:`SyncState.isFullySynced(now)` 是唯一被认可的"能否显示绿色"判断(任何未知即 false,**无 upstream ≠ 0**);`GitLogResult.upstreamBoundaryIndex` 找**最后一个**未推送提交(取前 N 行会朝危险方向出错)。
- **验证**: `./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify` —— blocker 修复提交时 **757 个 JVM 测试全绿**(基线 617);`:wire-protocol`/`:session-core`/`:api-client` 三处 koverVerify 均过。commit `3e5cfdc``3fe719f``35d32f4`
- **方法与其代价(如实记录)**: 分波多 agent(TDD builder → 对抗评审 → 修复)。**API 端持续 529 过载,13 个 agent 里 8 个、11 个里 9 个被打掉**;协议层和 REST 层两轮全灭后由 orchestrator 亲自补写。这也是为什么会话成本冲到 ~$360。对抗评审确实值 —— 滑动滚屏那个 CRITICAL 崩溃是 builder 漏掉、reviewer 抓到的。
- **仍未完成(不要把上面读成已完成)**:
- **A34 / A35 完全没有代码** —— `:app` 连 androidTest 源集都没有,也没有 benchmark 模块。AW6 条目把它们标成 `[x]` 是因为它们被转成了清单条目,不是实现。
- **设备 QA 仍约 0%** —— 仓库里唯一的真机证据是一张配对页的模拟器截图。上面每一条修复都只有 JVM 级验证。
- release 签名/版本号/minify;S2 FCM 真机投递;复制功能。
- `android/PROGRESS_ANDROID.md` 顶部已加"CORRECTION"段落说明上述全部。
- **后续同日完成(本条目最初写的"仍未完成"已不成立)**:
- **设备上真跑起来了**: `:app/src/androidTest/**` 67 个 instrumented 测试 + `macrobenchmark/`,在 `emulator-5554` 上对本仓库自己的服务器**全绿(67/0)**。含四个崩溃回归、A34 E2E(attach→output、ring buffer 重放、用第二个故意配坏的服务器测 spawn 失败、`POST /hook/decision` 解 gate、**F9 坏 Origin 拒绝**)、以及 plan §7 点名的 Compose 测试。模拟器要打通宿主机必须 `ALLOWED_ORIGINS=http://10.0.2.2:<port>` —— 服务端的 allowedOrigins 从网卡 IP 派生,而 10.0.2.2 是模拟器对宿主机的别名、不是任何网卡地址。
- **设备上又抓出第四个崩溃**: `onDraw``TerminalRow.getStyle``AIOOBE`,而 `PROGRESS_ANDROID.md` 原本把它写成"Accepted (not a defect) — 撕裂读会自我修正"。两半都是错的:它是进程直杀;而且"matches upstream Termux"也不成立 —— 上游 `append``MainThreadHandler` 在主线程调用,压根没有并发读者。根因是 `TerminalEmulator.resize` **先**发布新 `mColumns`、**后**才重新分配行数组,`length == index` 那个"可疑的一致性"是**扩容的确定性签名**。**而且这个竞态以前是休眠的** —— resize 从不发生,列数就不变,没有宽度差可撕;是本次把 resize 修好才让它变可达。修法:emulator mutation 全部挪到渲染线程(与上游一致),单写者模型不变。已先复现成 JVM 测试,所以这条回归以后不需要设备就能守住。
- **审计遗留 6 项 + 复制功能全部关闭**: 证书静默轮换(顺带修掉一个会让**每次续期都失败**的 `renew()` 解码 bug,并指出 iOS 有个"只轮换一次"的潜在缺陷 Android 通过从叶证书反推 `renewAfter` 避开了)、`/device/:id/recover`(走非 mTLS,因为过期证书没法给自己的恢复做认证)、W2 inject-queue 三条路由、未读水位持久化、host 移除 UI、`/config/ui` 被尊重且**fail-closed**、以及基于 `TerminalBuffer.getSelectedText` 自建的选择/复制层(stock ActionMode 的 Copy 处理器解引用空 session,所以不能恢复它)。
- **最终门禁**: `./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :macrobenchmark:assembleBenchmark koverVerify` → BUILD SUCCESSFUL,**1150 JVM 测试 / 0 失败**(基线 617);instrumented **67/0**。commit `3e5cfdc``390dd11` 共 9 个。
- **仍然开放**: **S2**(FCM 真机投递,需要 ≥2 台物理设备在 Doze/厂商省电下验证,模拟器答不了);`DEVICE_QA_CHECKLIST.md` 里没有自动化覆盖的其余设备可观测项;一个 instrumented 测试在**不传 host 参数**且模拟器被反复安装卸载后会杀掉测试进程,按文档化的调用方式+干净安装则稳定通过 —— 上真机再判。
- **本次合并时发现的新 parity 缺口(记录,未做)**: `develop` 在本分支之后新增了 **孤儿 tmux session** 功能 —— `OrphanSessionInfo` 类型 + 5 条路由(`GET /orphan-sessions``/count-idle``/:id/preview``DELETE /orphan-sessions``/:id`)。**Android 客户端完全不知道它们**。服务端刻意把它做成独立类型而非 `LiveSessionInfo` 上的 flag,所以现有解码器不受影响、不会解错 —— 这是一个新增缺口而不是回归。iOS 同样没有。
### 🗂 [x] A · 让服务器"看不见"的 tmux session 重新可达(2026-07-30,worktree `orphan-tmux-sessions`)
- **起因**: 用户问"关掉桌面 App 会不会把 tmux session 一起关掉"。查证过程中发现一个**功能缺口**:`tmux ls`**69 个** `web_*` session,只有 **5 个**有 client —— 另外 64 个**从 UI 完全不可达**,其中一个还跑着 7 个 agent 的 Claude Code。最老的到 6 月 26 日。