feat(android): wire the dead code, render the git panel, make the token gate real

Four agents' worth of :app wiring. Everything here existed as tested code with
zero production call sites, or as a screen rendering a shape the server stopped
sending months ago.

Dead code that shipped as features. All three were fully implemented and
unit-tested while being reachable from nothing:

  - Session-list preview thumbnails. SessionsHome called SessionListScreen
    without the `thumbnails` argument, so the `= null` default won and every
    row rendered a placeholder forever — on the home session chooser, whose
    entire purpose is seeing what each session is doing.
  - Quick-reply chips and their palette editor.
  - PointerContextMenu (large-screen secondary click).

ThumbnailPipeline's formal cross-review had been explicitly skipped when it was
written — PROGRESS_ANDROID.md said so in as many words, and it is concurrency
code, so nothing had ever reviewed OR executed it. It has now been reviewed and
kept: the LruCache key, the fair Semaphore(2), the Mutex-guarded in-flight
dedup and the NonCancellable in-flight release are all sound, and a retained
map entry would have poisoned a key forever.

The v0.6 project-detail git panel. Android rendered the pre-w6 shape: one bare
dirty dot, a bare hash+subject commit row, and a worktree heading that renamed
itself to "Branch" at n=1 — the exact behaviour G5 removed. Now the sync band,
unpushed marking with a single upstream boundary, and "Worktrees (n)". The rule
that drives it is enforced, not documented: exactly one state may render green,
and no-upstream is not that state.

Remote approvals are no longer blind. status.preview reaches GateBanner and
PlanGateSheet, so the user can see the command or diff they are approving
instead of a tool name. Absent preview stays an ordinary gate, not an error.

The WEBTERM_TOKEN gate is no longer inert. NetworkModule was calling
OkHttpClientFactory.create(identityProvider) with no cookieJar, so the shared
client ran on CookieJar.NO_COOKIES and none of the cookie plumbing did
anything. It now installs the real AuthCookieJar — one client, so the cookie
rides the WS upgrade too — backed by the Tink-sealed store, and binds
InMemoryAuthCookieStore if a cipher cannot be constructed rather than ever
persisting a shell credential in the clear. The persister runs on an IO scope
because OkHttp calls a CookieJar synchronously and must never block on
DataStore or Tink.

And the user-facing half that made the gate unusable: a token-gated host
answers the pairing probe's unauthenticated GET with 401, which the frozen
taxonomy could only report as "this port is running something else" — so such
a host was impossible to pair AND the copy was misleading. Pairing now routes
to a token prompt and resumes through the same cert-gate choke point. The
token is a parameter, never a field and never part of any UI state.

Docs corrected rather than left aspirational: the plan asserted a cleartext
allowlist "permitted only for private LAN/Tailscale CIDRs", which the platform
cannot express at all — the network-security-config format has no netmask or
prefix syntax. PROGRESS_ANDROID.md now opens with what "COMPLETE" actually
meant, and PROGRESS_LOG.md records the whole pass including what is still
undone: A34/A35 have no code, and device QA remains ~0%.

Verified: ./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify
-> BUILD SUCCESSFUL, 893 JVM tests, 0 failures (baseline was 617). The :app
run was re-measured with outputs deleted and --no-build-cache, and the APK
re-packaged, so Hilt graph validity is an observation rather than an
up-to-date claim.
This commit is contained in:
Yaojia Wang
2026-07-30 11:23:23 +02:00
parent 35d32f4670
commit 980dbaa928
36 changed files with 4249 additions and 168 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,32 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### 🤖 [~] Android 客户端"能装但不能用"修复 — blocker 已清,设备验证未做(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"段落说明上述全部。
- **下一步**: 模拟器(AVD `webterm`,已启动)上跑 instrumented 回归 —— 尤其是这次修掉的三个崩溃,那正是 JVM 测试抓不到、也正是本次修复存在的理由。
### 🌿 [x] w6 项目详情 Git 面板 — G1G7 全部完成(2026-07-29,worktree `project-detail-git-mockup`)
- **动机**: 详情页只有一个光秃秃的 `●`,回答不了"有没有 commit 没 push / 现在在哪个 worktree"。设计稿 `docs/mockups/project-detail-git.html`,计划 `docs/plans/w6-project-git-panel.md`