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

@@ -372,6 +372,80 @@ warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid
---
## ⚠️ CORRECTION (2026-07-30): "COMPLETE" below meant COMPILES, not WORKS
The section that follows claimed all 36 tasks had landed. That was true at the level it was measured —
the modules built, the APK assembled and ~484 JVM tests were green — but NOTHING had ever run on an
Android device or emulator, and the first thing that would have happened on one is a crash. An audit on
2026-07-30 found, and a repair pass fixed, the following. Read this before trusting anything below.
**Three defects made the app unusable on real hardware.** Each was invisible to the JVM suite for the
same structural reason: no JVM test instantiates an Android `View`.
1. **Every key press crashed.** `RemoteTerminalView` bound the forked emulator but never installed a
`TerminalViewClient`. The pinned Termux `TerminalView` dereferences `mClient` with no null guard on
the first line of the paths a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
offset 0, `onKeyUp`, `onKeyPreIme`). The KDoc claimed "A17 sets it" — A17 never did; only the
`HardwareKeyRouter` and the KeyBar shipped, and the KeyBar works precisely because it bypasses the
view. Installing a client is not sufficient: returning false continues into `mTermSession.write()`
at offset 150, and `mTermSession` is null forever (`TerminalSession` is final and forking a process
is exactly what this app must not do, §6.1). Fixed by a client that consumes the key itself, with
`KeyRouting` deliberately having NO defer-to-stock branch, so the crash is unrepresentable rather
than merely avoided. `KeyHandler` remains the authority for the key tables, so DECCKM still emits
`ESC O A`.
2. **No `resize` was ever sent.** `RemoteTerminalSession.updateSize` had zero call sites and the stock
fallback is dead (`TerminalView.updateSize` early-returns without a session), so the PTY stayed at
the server's 80x24 and every full-screen TUI rendered into the wrong box. `TerminalResizeDriver`
now drives it from real cell metrics, read via the PUBLIC `TerminalRenderer.getFontWidth()` /
`getFontLineSpacing()` accessors — no reflection (silently breaks under R8) and no re-measured
`Paint` (drifts from what the renderer actually draws, the §R5 risk).
3. **Bare-LAN connections were impossible.** `network_security_config.xml` was a self-labelled STUB
denying all cleartext, while `HostEndpoint` accepts `http` and derives `ws://`. Its own comment
promised a CIDR allowlist the platform cannot express. See plan §6.9 for the corrected posture.
**Adversarial review then found three more, and got one wrong:**
- **Swipe-scrolling inside an alternate-screen TUI still crashed.** The touch path bypasses
`TerminalViewClient` entirely: `doScroll` converts a scroll to `handleKeyCode` whenever the
alternate buffer is active, which dereferences `mTermSession`. Claude Code IS an alternate-screen
TUI, so this was a crash during ordinary use on the app's main screen. `TerminalScrollGesture` now
claims the vertical drag first and reproduces all three `doScroll` branches, closing the fling
runnable and `onGenericMotionEvent` with it.
- **`autofill()` dereferences `mTermSession` unguarded** while the view advertises itself
autofillable, so any password manager crashed the app. The subtree is excluded from the autofill
structure.
- The review also demanded stock text selection be RESTORED after the focus rework made it
unreachable. It cannot be: the ActionMode's own Copy and Paste handlers dereference the absent
session (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136), so a
reachable selection UI has an NPE on its Copy button. **Long press stays consumed and copy-out is
a recorded FEATURE GAP** — a first-party selection layer over `TerminalBuffer` + `ClipboardManager`
is the real fix. This is the one place the reviewer was wrong and the builder right.
**Silently-lost server data, now decoded:** the W2 `queue` frame had no `ServerMessageType` member so
every one was dropped as undecodable (the "N queued" badge could never appear), and W1
`status.preview` was not modelled at all, which made remote approvals **BLIND** — the user was asked to
approve a `Bash` call without seeing the command. Both now decode, with the rule that a broken preview
never costs the frame (`pending` is what makes the gate appear).
**Dead code that shipped as features:** `ThumbnailPipeline` (session-list previews), `QuickReply`
(chips + palette) and `PointerContextMenu` were fully implemented and unit-tested with ZERO production
call sites. All three are now wired. `ThumbnailPipeline`'s formal cross-review — explicitly skipped at
the time, as the AW3 entry below admits — has finally been run.
**Credential handling:** the `WEBTERM_TOKEN` cookie is now carried on REST and the WS upgrade, sealed
with Tink AEAD under an AndroidKeyStore key, **fail-closed** (a seal failure persists nothing rather
than falling back to plaintext), and `allowBackup=false` keeps a 30-day shell credential out of Google
Drive and D2D transfer.
**Still not done — do not read the section below as saying otherwise:**
- **A34 / A35 have no code.** There is no `:app` androidTest source set and no benchmark module; the
AW6 entry marking them `[x]` reflects them being converted into checklist bullets, not implemented.
- **Device QA remains ~0%.** One emulator smoke screenshot of the pairing screen is the only on-device
evidence in the repo. Every fix above is JVM-verified only.
- Release signing, versioning and minification; the S2 FCM real-handset spike; copy-out via selection.
---
## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1A36 + S1) landed
Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure