Files
web-terminal/docs/ANDROID_CLIENT_PLAN.md
Yaojia Wang f000a9d979 docs(plan): schedule the orphan tmux session client parity work
The server half shipped 2026-07-30 as feature A (4892fa7/f6ef19e/d39a0ab) and
both native clients are at zero. This is NOT a regression — OrphanSessionInfo is
deliberately a separate type rather than a flag on LiveSessionInfo, and
src/server.ts:600 names the reason: the clients decode /live-sessions, so a
variant shape there would have broken them.

It is worth planning anyway because SessionManager.shutdown only detaches the
tmux client for tmux-backed sessions (manager.ts:410-417), so a server restart
or a desktop-app quit turns every session into an orphan. The web launcher lists
them and re-adopts on tap; the phone still shows an empty list — which is the
walk-away case this product exists for.

The plan doc carries the eleven traps a client implementer will hit, each one a
shipped review finding or a documented server subtlety — notably that preview
returns cols/rows of literally 0 (both clients currently size thumbnails from
that response), that a 429 must not collapse to "no orphans", and that the
destructive confirmation must come from count-idle rather than card count (that
one nearly ended 33 shells while the dialog said 24).

Also corrects two now-false claims found while cross-referencing: the ROADMAP
row for tmux discovery was half-stale, and ANDROID_CLIENT_PLAN's do-not-consume
list still listed POST /projects/worktree (both clients consume it now) and
GET /sessions (iOS consumes it, Android does not — so "match iOS" now argues
for building it, not against).
2026-07-30 18:25:09 +02:00

585 lines
73 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Android Client Implementation Plan
> Version: v1.0 (final) · Target: functional parity with the WebTerm **iOS** client
> Lives under a new `android/` directory at the repo root. The app does not exist yet.
> Companion to [`ios/README.md`](../ios/README.md), [`TECH_DOC.md`](./TECH_DOC.md) (§4 protocol, §5.2 session model, §7 security), [`ARCHITECTURE.md`](./ARCHITECTURE.md) (invariants §8), and the multi-agent [`PLAN.md`](./PLAN.md).
> Same working discipline as the rest of the repo: phased waves, file-disjoint tasks with stable IDs and `Owns:` lists for multi-agent parallelism, TDD, progress tracked in `PROGRESS_LOG.md`.
>
> **Framing note (per architecture review):** this plan mirrors the **iOS package *set*** and its "dependencies flow down" rule. It is **not** a file-for-file port. Two deliberate restructurings: (1) both transports (iOS keeps `URLSessionTermTransport` inside `SessionCore` and `URLSessionHTTPTransport` in `App/Wiring`) are **consolidated into `:transport-okhttp`** so `:session-core` becomes genuinely pure and runs under `runTest` virtual time; (2) a `:terminal-view` module is added for the Termux wrap. These are improvements, called out where they relocate iOS logic.
---
## 0. First principles (inherit verbatim from the server contract)
Four invariants govern every decision below. The first three are copied from the server contract, not reinterpreted; the fourth is the Android concurrency contract that replaces the Swift actor model.
1. **The client is a byte-shuttle half, not a terminal.** The app never parses ANSI/terminal semantics — the emulator library does. Output bytes are an opaque blob fed verbatim to the emulator; `input.data` is raw keyboard bytes passed through unfiltered (ARCHITECTURE invariant #1, #9).
2. **PTY lifecycle ≠ WebSocket lifecycle.** A WS disconnect must NEVER kill the server PTY; `close()` == detach. Sessions are keyed by `sessionId`; reconnect carries it so the server replays the same ring buffer (TECH_DOC §5.2). This is what makes the Android background story (§ push) work: the phone reconnects and replays on resume; push is the wake channel while away.
3. **Origin-header validation on the WS handshake is the one defense that cannot be skipped** (CSWSH, TECH_DOC §7). The `Origin` string must byte-match the browser's `new URL()` serialization or the server 401s the upgrade. There is exactly ONE place that derives it (`HostEndpoint`, frozen in `:wire-protocol`), stamped on the WS handshake and the guarded HTTP routes only.
4. **Confinement contract (the actor analogue).** Engine state lives only on the engine's `limitedParallelism(1)` dispatcher; emulator/scrollback state lives only on `Dispatchers.Main.immediate`. The **only** cross-boundary calls are `engine.send()` (UI→engine) and the event fan-out (engine→UI, per-consumer `Channel`s). This is written down so a future edit cannot touch emulator state off-Main or engine state off-confinement and reintroduce the data races the actor model prevented.
---
## 1. Goal & parity scope
Reach functional parity with the iOS client's shipped feature set (P0 + P1). Every iOS feature is enumerated below and marked **IN** (build now), **IN·adapted** (build, but the platform forces a different mechanism), or **DEFERRED** (post-parity), with rationale. Feature-parity review surfaced five user-facing surfaces missing from the draft; all are now **IN** with owning tasks (§5): the **reconnect/EXIT banner**, the **activity-timeline sheet**, the **device-certificate management screen**, **new-session-in-cwd** (phone toolbar + exit-banner action), and the **continue-last-session cold-start UX**.
### P0 — daily-usable pocket cockpit
| iOS feature | Android status | Rationale / adaptation |
|---|---|---|
| Native terminal (SwiftTerm), full ANSI/true-color, scrollback replay on attach | **IN·adapted** | No SwiftTerm on JVM. Use **Termux `terminal-emulator` + `terminal-view`** (the battle-tested analogue). The single hardest port — see §6. Approach validated by throwaway spike **S1** before AW3 commits; license gated by **R2**. |
| Pairing: QR scan / manual URL, confirm-before-network, two-step probe, secure save, §5.4 warning tiers | **IN·adapted** | CameraX + ML Kit for QR; Tink AEAD for storage. Confirm-gate + probe + tier logic port verbatim as pure Kotlin. **Cert import is NOT part of pairing** (matches iOS — pairing is URL/QR+probe only); it lives in the separate device-cert screen (A27). |
| Session list (chooser + dashboard): status badge, telemetry chips, preview thumbnail, cols×rows, swipe-to-kill, pull-to-refresh, multi-host + host menu | **IN** | MVVM/HTTP port; thumbnails are the hard sub-piece (§6.7). Host menu (the app's only settings surface) explicitly enumerated in A20. `lastOutputAt` is already serialized server-side (`src/session/manager.ts`), so unread dots are unblocked. |
| Reconnect / connection-status / EXIT banner | **IN** | ios `ReconnectBanner`: `connecting` / `reconnecting(attempt, countdown)` / `failed(replayTooLarge, actionable copy, no spinner)` / `exited(code, reason, spawn-failure 1)` + on-exit "new session" action. Terminal phases (failed/exited) outrank transient phases (connecting/reconnecting), mirroring `TerminalViewModel.bannerModel`. Owner: A21. |
| Remote approve/reject: tool 2-button card + plan 3-way sheet, haptic on arrival | **IN** | Compose `Card` + `ModalBottomSheet`; the two-line epoch stale-guard is security-load-bearing and ports 1:1. |
| Away digest on reattach | **IN** | Pure `AwayDigest` reducer ports directly. Its "expand" affordance opens the activity-timeline sheet (A28). |
| Sessions survive background/kill/network loss; 1 live WS + HTTP poll; 1s→30s backoff; 25s ping | **IN·adapted** | Foreground-only WS (STARTED-scoped); background wake via FCM. **No background foreground-service** (see §9 R9 decision). |
| Mobile key-bar (Esc/Esc²/⇧Tab/arrows/Enter=`\r`/^C^R^O^L^T^B^D/Tab/`/`), raw send, hardware chords | **IN** | `KeyByteMap` ports byte-for-byte; key-bar is a Compose overlay pinned above IME (`WindowInsets.ime`). |
| New session in current cwd (`attach(null, cwd)`) | **IN** | ios TerminalScreen toolbar "在当前目录开新会话" + the exit-banner "开新会话" action, both routing to `attach(null, cwd)`. Both the phone toolbar entry and the exit-banner action are owned by A21 (the iPad pointer-menu variant is A26). |
| Privacy shade when app inactive | **IN·adapted** | `FLAG_SECURE` + a cover Composable on `ON_STOP`; blanks the recents thumbnail. |
| Notifications P0 (existing ntfy bridge, zero new code, default-off) | **IN (free)** | ntfy is server-side + the ntfy Android app; nothing to build. The pre-FCM stopgap. |
### P1 — walk-away complete
| iOS feature | Android status | Rationale / adaptation |
|---|---|---|
| Push + lock-screen Allow/Deny (single-use token, no full app open on Deny) | **IN·adapted (FCM)** | FCM has no server-defined action buttons → **data-only high-priority** messages; the client builds the notification + buttons locally. **Deny → `BroadcastReceiver`** (`goAsync()` + expedited POST). **Allow → a translucent, `excludeFromRecents` trampoline `Activity`** that hosts `BiometricPrompt`, then POSTs — a `BroadcastReceiver`/`Service` **cannot** present `BiometricPrompt` (see R1 mustFix). Requires a new server sender (§4.5). |
| Deep links `webterminal://open?host=&join=`, UUID-whitelist, cold/warm, push taps share the router | **IN** | Custom scheme intent-filter + verified App Links; one ported `DeepLinkRouter`. |
| Device-certificate management screen (import / rotate / remove + issuer-CN/expiry/expired-warning summary) | **IN** | ios `ClientCertScreen` + `ClientCertViewModel`, reached from the session-list host menu "设备证书". A **distinct screen**, not folded into pairing. Owner: A27. |
| Projects: namespaced repos, favourites/collapse synced via `/prefs` (unknown-key preserving), dirty badge, detail page, "open Claude here", iPad multi-column grid | **IN** | `ProjectGrouping` must produce **byte-identical group keys** to web/iOS (shared `/prefs`). Regular-width grid + adaptive sheet detents (ios `ProjectsLayout`) included in A23. |
| Multi-session switcher: unread dots (`lastOutputAt`), sanitized OSC titles | **IN** | `UnreadLedger` + `TitleSanitizer` port 1:1 (both pure reducers in `:session-core`). |
| Activity timeline (`/events` drill-down) | **IN** | ios `TimelineSheet` + `TimelineViewModel` over `GET /live-sessions/:id/events`, reachable from the away-digest expand affordance. Owner: A28. |
| Diff viewer (read-only, staged/unstaged, per-file hunks) | **IN** | staged flag sent as `'1'/'0'` (server matches `=== '1'`), like iOS — not `true/false`. |
| Quick-reply chips + editable palette (floats while a gate is held) | **IN** | `QuickReplyPalette` CRUD ports 1:1; DataStore replaces UserDefaults. |
| Continue-last-session cold-start | **IN** | `ColdStartPolicy` route (no host → pairing, else sessions) + "继续上次会话" re-entry banner (stack + sidebar) + `LastSessionStore` lifecycle (`SessionActivityBridge` sets on `.adopted`, clears on `.exited`). Owner: A29. |
| Session thumbnails: off-screen render, concurrency-capped, cached `(sessionId, lastOutputAt)` | **IN·adapted** | Off-screen `TerminalEmulator` buffer + manual `Canvas` cell painter (avoids iOS's off-screen-view snapshot / `CADisplayLink` leak). |
### iPad-equivalent (large screen / foldable)
| iOS feature | Android status | Rationale / adaptation |
|---|---|---|
| Adaptive split (regular = sidebar+detail; compact = stack), one `LayoutPolicy` | **IN·adapted** | Material 3 Adaptive: `NavigationSuiteScaffold` + `ListDetailPaneScaffold`, keyed on `currentWindowAdaptiveInfo().windowSizeClass`. |
| Hardware-keyboard auto-hide of key-bar | **IN·adapted** | `InputDevice`/`onKeyEvent` heuristic (no `GCKeyboard` equivalent — less reliable, accepted). |
| Pointer context menu (open-in-cwd / kill / copy) | **IN·adapted** | `Modifier.pointerInput` detecting `PointerButton.Secondary` → anchored `DropdownMenu` (Compose `ContextMenuArea` is a Compose-Desktop construct, not stable on Android — corrected per platform review). Gated on `Expanded` + `smallestScreenWidthDp >= 600`. |
### Design system
| iOS feature | Android status |
|---|---|
| Frozen design system: amber-gold accent `#E3A64A` dark / `#C9892F` light, semantic status colors, 8pt scale, tabular monospace numerals, reduce-motion-gated animation/haptics, dark-first | **IN** — Compose Material 3 theme, one token file, dark-appearance-first. Terminal canvas fixed `#100F0D`/`#ECE9E3` gold caret independent of app theme (reads identically to desktop). |
### Explicitly DEFERRED (post-parity, called out so scope is closed)
- **`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 (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.
---
## 2. Tech stack decision (locked)
| Concern | Choice | One-line rationale |
|---|---|---|
| Language | **Kotlin 2.x** (K2), JVM target 17 | Coroutines/Flow map cleanly onto Swift actors/AsyncStreams; pure modules stay JVM-testable. |
| Min / target SDK | **minSdk 29** (Android 10) / **targetSdk 35** (Android 15) | 29 gives modern TLS + scoped storage without legacy branches; 35 for App Links + FGS/notification rules. |
| UI toolkit | **Jetpack Compose** + **Material 3 Adaptive** (`androidx.compose.material3.adaptive`) | Direct analogue of SwiftUI + size-class adaptive split. |
| Terminal renderer | **Termux `terminal-emulator` + `terminal-view`** (primary; **Apache-2.0**, consumable via **JitPack** `com.termux:termux-app:terminal-view:0.118.0` — vendoring optional for version-pinning) | Only battle-tested JVM VT100/xterm emulator with 24-bit true-color; the direct SwiftTerm analogue. **These two libs are Apache-2.0** (only `termux-shared`/app module are GPLv3 — do NOT depend on those). Actively maintained (0.119.0-beta.3, 2025-06). License de-risked → §9 R2; seam proof → S1 spike. |
| WebSocket + HTTP | **OkHttp 4.x** (`WebSocket` + `WebSocketListener`, and REST) — one shared `OkHttpClient` | One client → mTLS `SSLSocketFactory` applies to both WS and REST uniformly; supports a custom `Origin` header on the WS handshake. |
| JSON | **kotlinx.serialization** (`Json { ignoreUnknownKeys = true; isLenient = true }`) for **decode**; **hand-rolled `StringBuilder` codec** for **encode** | Decode tolerant; encode must be byte-identical to `JSON.stringify` (explicit `sessionId:null`, top-level `approve.mode`, JS control-char escaping) — defaults won't guarantee that (R3). |
| Concurrency | **kotlinx-coroutines**`Dispatchers.Default.limitedParallelism(1)` for engine confinement; **per-consumer `Channel`s** for the event bus (not a single `SharedFlow` — see R10) | Structured concurrency replaces the Swift actor; single-thread confinement replaces actor isolation. |
| DI | **Hilt** (Dagger) | One shared `OkHttpClient` singleton, testable seams, standard Android DI. |
| Persistence (non-secret) | **Jetpack DataStore (Preferences + Proto)** | Host list, last-session id, unread watermarks, quick-reply palette, prefs blob. |
| 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**, 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). |
---
## 3. Module / package architecture (mirrors the iOS package set)
Gradle multi-module. Mapping to the iOS packages, with the two additions noted in the framing block.
```
iOS SPM package Android Gradle module Kind
──────────────────────────────────────────────────────────────────────────────
WireProtocol → :wire-protocol pure Kotlin/JVM (testable)
(HostEndpoint here) — Origin derivation frozen in the contract
SessionCore (reducers) → :session-core pure Kotlin/JVM (testable)
— incl. TitleSanitizer (pure, security-relevant)
URLSessionTermTransport→ :transport-okhttp JVM (OkHttp) — impls interfaces
URLSessionHTTPTransport→ :transport-okhttp (relocated here from iOS App/Wiring)
APIClient → :api-client pure Kotlin/JVM (testable)
HostRegistry → :host-registry Kotlin + DataStore (storage behind iface)
ClientTLS → :client-tls SPLIT: pure half (JVM) + framework half
(SwiftTerm host view) → :terminal-view Android-framework-bound (Termux wrap)
TestSupport → :test-support pure Kotlin/JVM (fakes)
App/WebTerm → :app Android app (Compose, Hilt, FCM)
```
### Dependency graph (arrows = "depends on"; nothing points upward)
```
:app (Compose UI, ViewModels, DI, FCM, DeepLinkRouter, DesignSystem, EventBus)
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls (framework half)
│ │ │ │ │
│ │ │ │ ▼
│ │ │ │ :client-tls (pure half)
└───────┬───────┴────────┴──────┬───────┴───────────────┘
▼ ▼
:wire-protocol ◀────── :transport-okhttp
(models, HostEndpoint, (impls TermTransport + HttpTransport,
TermTransport/HttpTransport, consumes ClientIdentityProvider)
PingableTermTransport)
└──────── :test-support (FakeTransport / FakeHttpTransport / FakeTimeSource) → test source sets only
```
**Boundary note (`:terminal-view`):** `:terminal-view` depends **only on `:wire-protocol`**`RemoteTerminalSession` consumes `ClientMessage` and raw `ByteArray`. The `SessionEvent → ByteArray` decode happens in `:app`'s `TerminalSessionController` (A21), **not** in the terminal module. There is **no** `:terminal-view → :session-core` edge (draft ambiguity resolved per architecture review — A16 depends on the wiring freeze, not on the engine module).
Rules (mirroring ARCHITECTURE §1 "dependencies only flow down"):
- **`:wire-protocol` is the frozen shared contract** — the Android analogue of `src/types.ts` + WireProtocol. It owns `ClientMessage`/`ServerMessage` sealed interfaces, `MessageCodec`, `Validation`, `WireConstants`, **`HostEndpoint` (Origin/wsURL derivation — moved here from the draft's A4 so both transport and api-client can depend on it without a same-wave sibling dependency)**, and the boundary interfaces `TermTransport` / `HttpTransport` / **`PingableTermTransport`** (a sub-interface so the pure `PingScheduler` drives a ping only through transports that support it, matching iOS's `transport as? any PingableTermTransport`). No Android imports. **New wire types are added here only** (coordination point).
- **`:session-core` is pure** — `SessionEngine`, `ReconnectMachine`, `PingScheduler`, `GateTracker`/`GateState`, `AwayDigest`, `UnreadLedger`, `KeyByteMap`, **`TitleSanitizer`**, `SessionEvent`. Depends only on `:wire-protocol`; consumes `TermTransport` by interface — never touches OkHttp/Android; runs entirely under `runTest` virtual time. `TitleSanitizer` lives here (not behind `:terminal-view`) because it is a pure, security-relevant reducer consumed by both the terminal and the session-list/switcher UIs.
- **`:transport-okhttp`** implements `TermTransport`/`PingableTermTransport` (WS) and `HttpTransport` (REST). It takes an injected `ClientIdentityProvider` (from `:client-tls`) so mTLS is wired without `:session-core` knowing about it.
- **`:client-tls` is split like `:host-registry`** — a **pure Kotlin/JVM half** (PKCS#12 structural parse via `KeyStore("PKCS12")`, `X509KeyManager` alias/`getPrivateKey` selection logic, `CertificateSummary` parsing, `PairingError`/`HostClassifier` warning-tier mapping) tested at JVM speed and **in the 80% Kover gate**; and a **thin framework half** (AndroidKeyStore import for device binding + Tink AEAD storage + `connectionPool.evictAll()` on rotation) tested instrumented. This restores the iOS test posture (iOS unit-tests `PKCS12Importer`/`CertificateSummary`/`MutualTLSChallengeResponder`) and avoids leaning on Robolectric for AndroidKeyStore.
**Pure-Kotlin (JVM-unit-testable, the 80%-coverage targets):** `:wire-protocol`, `:session-core`, `:api-client`, the logic half of `:host-registry`, **and the pure half of `:client-tls`**.
**Android-framework-bound (instrumented + device QA):** `:terminal-view`, the framework half of `:client-tls`, the storage half of `:host-registry`, and `:app`.
### `:app` internal package layout (mirrors `App/WebTerm/`)
```
android/app/src/main/java/wang/yaojia/webterm/
├── di/ Hilt modules (OkHttp singleton, engine factory, stores, per-feature boundaries)
├── designsystem/ Theme.kt, Tokens.kt, StatusBadge, TelemetryChip, Card…
├── screens/ PairingScreen, SessionListScreen, TerminalScreen, ProjectsScreen,
│ ProjectDetailScreen, DiffScreen, ClientCertScreen, TimelineSheet
├── viewmodels/ SessionListViewModel, GateViewModel, ProjectsViewModel, ProjectDetailViewModel,
│ DiffViewModel, PairingViewModel, ClientCertViewModel, TimelineViewModel
├── wiring/ TerminalSessionController, EventBus (per-consumer channels), SessionActivityBridge,
│ ThumbnailPipeline, ColdStartPolicy, AppEnvironment (composition root),
│ RetainedSessionHolder (@ActivityRetainedScoped / nav-scoped)
├── components/ KeyBar, QuickReply, QuickReplyStore, GateBanner, PlanGateSheet, ReconnectBanner,
│ AwayDigestView, TelemetryChips, TerminalContextMenu, ContinueLastBanner
├── push/ FcmService, DenyBroadcastReceiver, AllowTrampolineActivity, PushRegistrar, NotificationBuilder
└── nav/ DeepLinkRouter, NavGraph
```
---
## 4. The server contract (single reference for the Android side)
The complete surface the client uses, confirmed against `src/server.ts`. **The server is unchanged for everything except one additive P1 touch-point (§4.5).**
### 4.1 WebSocket — `wss://<host>/term` (path from `WireConstants.wsPath`)
Single connection, JSON text frames. `attach` MUST be the first frame on every (re)connect. `Origin` header stamped from `HostEndpoint.originHeader`.
**Client → server**
| type | shape | notes |
|---|---|---|
| `attach` | `{ "type":"attach", "sessionId": <uuid-v4-lowercase> \| null, "cwd"?: "/abs/path" }` | `sessionId` key **always present** (JSON `null` for new session — server rejects a missing key). `cwd` appended only when `sessionId` is null. |
| `input` | `{ "type":"input", "data": <string> }` | raw keyboard bytes, verbatim, never filtered. |
| `resize` | `{ "type":"resize", "cols": <int 1..1000>, "rows": <int 1..1000> }` | own message → `ioctl(TIOCSWINSZ)`→SIGWINCH. Validate range before send (server silently drops out-of-range). |
| `approve` | `{ "type":"approve", "mode"?: "acceptEdits" \| "default" }` | `mode` is a **TOP-LEVEL** key. No key = plain approve. |
| `reject` | `{ "type":"reject" }` | |
**Server → client** (every frame untrusted; undecodable → drop + count, never crash)
| type | shape | notes |
|---|---|---|
| `attached` | `{ "type":"attached", "sessionId": <uuid> }` | **always adopt** the server-issued id. Must pass v4 regex AND `UUID` parse. |
| `output` | `{ "type":"output", "data": <string> }` | opaque ANSI/UTF-8 → emulator verbatim. Ring-buffer replay arrives as one large frame right after attach (prefixed by `ESC[0m` — do NOT strip). |
| `exit` | `{ "type":"exit", "code": <int>, "reason"?: <string> }` | terminal; `code == -1` = spawn-failure, non-retryable, `reason` required. |
| `status` | `{ "type":"status", "status": <ClaudeStatus>, "detail"?, "pending"?: bool, "gate"?: "plan"\|"tool" }` | cockpit side-channel. `pending` absent → false; unknown/absent `gate` while pending → treat as tool. |
| `telemetry` | `{ "type":"telemetry", "at": <ms>, ...optional metrics }` | `at` is the only required field (missing → drop whole frame). |
Client-side frame max message size raised to **16 MiB** (guard with a client-side cap) to receive ring-buffer replay; oversized → non-retryable `replayTooLarge` (§9 R6). The large replay append is **chunked and off-main** (§6.2).
### 4.2 Read-only HTTP (GET, **no** `Origin` header)
| Route | Returns |
|---|---|
| `GET /live-sessions` | `[LiveSessionInfo]``{ id, cols, rows, status, cwd?, title?, telemetry?, lastOutputAt? }` (list-lossy; unknown `status``.unknown`; `lastOutputAt` already serialized server-side → unread dots unblocked) |
| `GET /live-sessions/:id/preview` | `SessionPreview { id, cols, rows, data }``data` ~24 KiB opaque ANSI (≤256 KiB cap client-side) |
| `GET /live-sessions/:id/events` | `[TimelineEvent]` — away-digest + activity-timeline source (consumed by A28) |
| `GET /config/ui` | `UiConfig { allowAutoMode }` |
| `GET /projects` | `[ProjectInfo]` (list-lossy) |
| `GET /projects/detail?path=` | `ProjectDetail { sessions, worktrees, claudeMd, … }` |
| `GET /projects/diff?path=&staged=1\|0` | `DiffResult` (files→hunks→lines; **`staged` must be `'1'`/`'0'`**) |
| `GET /prefs` | `UiPrefs` (opaque JSON object — **preserve unknown top-level keys**) |
### 4.3 Guarded HTTP (state-changing — MUST send `Origin == HostEndpoint.originHeader` byte-equal; foreign/missing → 403)
| Route | Body | Returns |
|---|---|---|
| `DELETE /live-sessions/:id` | — | 204 (404 = already gone = success) |
| `POST /hook/decision` | `{ sessionId, decision: "allow"\|"deny", token }` | 204 (400 malformed, **403 stale/used/mismatched token**, 429 ≤10/min). Token single-use, from push payload only — never persist/log. |
| `PUT /prefs` | full blob ≤64 KB | 200 echo (replaces whole blob → rewrite the unknown-key-preserving object) |
> `DELETE /live-sessions` (kill-all) is **NOT consumed** (iOS lacks it). Do not build it.
### 4.4 Endpoints the iOS client does NOT use (match iOS — do not consume unless expanding scope)
`GET /sessions`, `POST /hook`, `POST /hook/permission`, `POST /hook/status`, `POST /open-in-editor`, `POST /projects/worktree`, `DELETE /live-sessions` (kill-all). Push web-only: `GET /push/vapid-key`, `POST/DELETE /push/subscribe` (Android uses FCM, not VAPID).
> **Amendment (2026-07-30).** This list is out of date on two entries — verified on disk, not assumed:
> - **`POST /projects/worktree`** is consumed by **both** clients now (`android/app/.../viewmodels/WorktreeViewModel.kt`, `ios/App/WebTerm/Screens/WorktreeSheet.swift`). Remove it from this list.
> - **`GET /sessions`** (`claude --resume` history) is consumed by **iOS only** (`ios/Packages/APIClient/Sources/APIClient/History.swift:71`); Android has zero references. So the section's premise — "match iOS" — now *argues for* building it on Android, rather than against. That is an open parity gap, not a prohibition.
>
> Both changes landed in the `ios-completion` wave — see [`plans/ios-completion.md`](./plans/ios-completion.md).
>
> **Planned scope expansion, not yet built:** the five `/orphan-sessions*` routes (feature **A**, shipped server-side 2026-07-30) are a *deliberate* future addition for both clients — plan in [`plans/w7-orphan-session-clients.md`](./plans/w7-orphan-session-clients.md). They are **not** listed in §4.2/§4.3 yet, on purpose: this doc must never claim a capability that does not exist. Move them there when the work lands, not before.
### 4.5 The ONE additive server change Android requires (P1 push)
APNs routes (`POST/DELETE /push/apns-token`) do not transfer to FCM. Add, mirroring `src/push/apns.ts` behind the existing `combineNotifyServices(...)` seam (zero new event paths — `notify(session, cls, token?)` already carries needs-input/done):
- **NEW `src/push/fcm.ts`** — a `NotifyService` impl: OAuth2 bearer via **`google-auth-library`** (`GoogleAuth`/`JWT` with the service-account key + scope `https://www.googleapis.com/auth/firebase.messaging`; the lib handles token mint + ~1h refresh) → `POST fcm.googleapis.com/v1/projects/{PID}/messages:send` with `{message:{token, data:{sessionId,cls,token?}, android:{priority:"high"|"normal", ttl}}}`. **Data-only** (no `notification` block). Prune on `UNREGISTERED`/`INVALID_ARGUMENT`; the lib auto-refreshes on 401. **DECIDED (R7): use `google-auth-library`** (add as a server dep) rather than hand-rolling RS256 — less crypto to own/test than `apns.ts`'s hand-rolled JWT.
- **NEW route `POST/DELETE /push/fcm-token`** — mirrors the apns-token route but with an **FCM-token validator that is intentionally loose** (non-empty, bounded max length, `base64url` charset plus `:` — FCM token format/length is undocumented and changes; a strict `{140-170}` regex risks rejecting valid tokens). Same `Origin`-guard + rate limit.
- **NEW env group `FCM_*`** (project id + service-account key path), all-or-disabled like `loadApnsConfig`.
Payload minimization carried over verbatim: `data` holds only `sessionId`, `cls`, and (gate only) `token` — never cwd/command/terminal bytes.
---
## 5. Phased task plan (waves for multi-agent parallelism)
Same rules as `PLAN.md` §0: each task has a stable ID, an exclusive `Owns:` list, `Depends:` (interface, not implementation), and a `Verify:` step. `:wire-protocol` types **including `HostEndpoint`** are frozen by **A2** — other tasks only read them. **A15 freezes the `:app` wiring + DI contracts** before AW4 fans out. `PROGRESS_LOG.md` is orchestrator-only. Dispatch ~35 file-disjoint same-wave tasks concurrently; use `isolation: worktree` for concurrent Gradle/test runs.
Ordering follows the mandate: **protocol+transport → session core → terminal render → UI screens → cockpit/push/mTLS** — with two throwaway spikes (**S1** renderer, **S2** FCM) run in AW1 so both Critical risks are measured with schedule buffer remaining.
```
AW0 foundations (serial, blocks all)
A1 gradle scaffold → A2 :wire-protocol (FROZEN contract incl. HostEndpoint + PingableTermTransport) → A3 :test-support fakes
AW1 leaf modules + de-risking spikes (parallel — highest fan-out)
A4 MessageCodec + Validation (:wire-protocol, TDD golden vectors)
A5 :session-core timing/lifecycle reducers (ReconnectMachine, PingScheduler, SessionEvent)
A6 :session-core cockpit reducers (GateTracker/GateState, AwayDigest, UnreadLedger, KeyByteMap, TitleSanitizer)
A7 :transport-okhttp (TermTransport/PingableTermTransport WS + HttpTransport, server-cert trust config)
A8 :api-client routes (12 REST, tolerant decode, Origin-iff-guarded, strict query, prefs unknown-key)
A9 :api-client pairing (probe/PairingError/HostClassifier tiers)
A10 :client-tls pure half (PKCS12 parse, X509KeyManager alias logic, CertificateSummary, warning tiers)
A11 :client-tls framework half (AndroidKeyStore import, Tink AEAD, connectionPool.evictAll on rotation)
A12 :host-registry (Host, HostStore iface + DataStore, LastSessionStore)
A13 :app designsystem (theme tokens, primitives) + Hilt skeleton
S1 RENDERER SPIKE (throwaway): Termux emulator, no local process, canned WS bytes → one rendered screen
S2 FCM DELIVERY SPIKE (real device): data-only high-priority delivery under Doze / OEM battery managers
AW2 session engine + wiring freeze (convergence #1)
A14 SessionEngine lifecycle (generation-safe cancel, attach-first, backoff, ping, gate reduce, digest) [Dep A4,A5,A6,A7]
A15 FREEZE :app wiring + DI contracts (EventBus per-consumer channels, TerminalSessionController surface,
AppEnvironment, RetainedSessionHolder scope, ColdStartPolicy seam, per-feature Hilt boundaries, confinement invariant) [Dep A14]
AW3 terminal render (the XL piece — S1 must be green; R2 already resolved: Apache-2.0 via JitPack)
A16 :terminal-view — RemoteTerminalSession (Termux terminal-view via JitPack 0.118.0 — or vendor for pin; NO local process; off-main append, resize math) [Dep A15; consumes :wire-protocol only]
A17 KeyBar overlay (IME-bypass) + hardware chords + DECCKM split [Dep A16, A6 KeyByteMap]
A18 ThumbnailPipeline (off-screen emulator → Canvas cell painter, LRU, Semaphore(2), mTLS fetch) [Dep A16, A8, A11] (ML)
AW4 UI screens (parallel — file-disjoint by screen; A15 wiring frozen)
A19 Pairing flow (QR CameraX/MLKit + confirm-gate + warning tiers; NO cert import) [Dep A9, A12]
A20 Session list + dashboard + host menu (poll STARTED-scoped, rows, swipe-kill per-session, thumbnails,
multi-host switch + checkmark, host-menu: 配对新主机 / 设备证书) [Dep A8, A18]
A21 Terminal screen wiring + reconnect/EXIT banner + new-session-in-cwd toolbar
(TerminalSessionController, EventBus fan-out, privacy shade, RetainedSessionHolder, lifecycle rebuild) [Dep A14, A15, A16, A17]
A22 Gate/cockpit surfaces (GateViewModel, banner + plan sheet + telemetry chips + away digest, two-line stale-guard, haptics) [Dep A14]
A23 Projects + ProjectGrouping + detail + "open Claude here" + iPad projects grid/sheet detents [Dep A8]
A24 Diff viewer (flattened lazy list, staged '1'/'0', lossy decode) [Dep A8]
A25 Quick-reply chips + editable palette (DataStore) [Dep A6 KeyByteMap]
A26 Adaptive large-screen layout (ListDetail/NavigationSuite, pointer context menu via pointerInput) [Dep A20, A21]
A27 ClientCertScreen (device cert import/rotate/remove + summary; host-menu reachable) [Dep A9, A10, A11, A12]
A28 Activity-timeline sheet (TimelineSheet + TimelineViewModel over /events, wired to away-digest expand) [Dep A8, A22]
A29 Cold-start UX (ColdStartPolicy route + continue-last-session banner stack+sidebar + LastSessionStore lifecycle via SessionActivityBridge) [Dep A12, A14, A20]
AW5 cockpit / push / mTLS integration (parallel where disjoint — pull forward alongside AW2/AW3 where deps allow)
A30 FCM client (FcmService data-only → NotificationBuilder; Deny→BroadcastReceiver goAsync;
Allow→translucent trampoline Activity hosting BiometricPrompt→expedited POST) [Dep A8, A12]
A31 PushRegistrar (POST /push/fcm-token per host, POST_NOTIFICATIONS, self-heal) [Dep A8, A12]
A32 DeepLinkRouter + custom scheme + verified App Links (assetlinks) + push-tap routing [Dep A21]
A33 SERVER: src/push/fcm.ts + POST/DELETE /push/fcm-token + FCM_* env [server-side, no client dep] ← routed to server module owner; startable day 1
AW6 integration + acceptance
A34 :app instrumented E2E against real `npm start` (attach→attached→output, reconnect replay, kill, hook/decision, bad-Origin reject)
A35 One macrobenchmark/Espresso happy path: pair → attach → type → approve
A36 Coverage gate (Kover ≥80% on pure modules incl. :client-tls pure half) + README(android/) + PROGRESS_LOG finalize + device-QA checklist
```
### Task detail (Owns / Verify highlights)
- **A1** `Owns: android/settings.gradle.kts, android/build.gradle.kts, android/gradle/libs.versions.toml, android/**/build.gradle.kts stubs, android/README.md`. `Verify:` `./gradlew help` + empty modules assemble.
- **A2** `Owns: :wire-protocol/**` model + interfaces (sealed `ClientMessage`/`ServerMessage`, `TermTransport`, `HttpTransport`, `PingableTermTransport`, `WireConstants`, **`HostEndpoint`**, enums). **Freezes the shared contract incl. Origin derivation.** `Verify:` compiles; consumers can import.
- **A4** `Owns: :wire-protocol MessageCodec.kt, Validation.kt + tests`. `Verify:` golden-vector encode byte-equality vs captured web-client frames; `HostEndpoint` origin table vs `src/http/origin.ts` (in A2's tests but re-asserted here).
- **A5** `Owns: :session-core/{ReconnectMachine,PingScheduler,SessionEvent}.kt + tests`. `Verify:` backoff ladder 1→2→4→8→16→30; no-reset-on-foreground; ping 25s/2-miss under virtual time.
- **A6** `Owns: :session-core/{GateTracker,GateState,AwayDigest,UnreadLedger,KeyByteMap,TitleSanitizer,SessionEvent-cockpit}.kt + tests`. `Verify:` two-line epoch guard + `canDecide`; digest once-per-reconnect, all-zero suppressed; `KeyByteMap == public/keybar.ts`; `TitleSanitizer` strips bidi/zero-width + 256-char cap.
- **A7** `Owns: :transport-okhttp/**`. `Verify:` `Origin` header reaches server (integration); clean-finish vs error preserved via `channelFlow`; server-cert trust: default trust for tunnel/Tailscale, `ws://` allowlist for LAN.
- **A10** `Owns: :client-tls pure half (Pkcs12Parse.kt, ClientKeyManagerLogic.kt, CertificateSummary.kt, HostClassifier.kt) + tests`. `Verify:` wrong-passphrase → `UnrecoverableKeyException`; alias selection; classifier tiers (loopback/tailscale/privateLAN/public, fail-safe unknown→public). **In the Kover gate.**
- **A11** `Owns: :client-tls framework half (AndroidKeyStoreImporter.kt, TinkCertStore.kt, IdentityRepository.kt) + androidTest`. `Verify:` (instrumented) mid-run-imported cert presented on next handshake with no relaunch; `connectionPool.evictAll()` drops old-identity pooled/resumed connections.
- **A14** `Owns: :session-core/SessionEngine.kt + SessionEngineTest.kt`. `Verify:` FakeTransport drives attach-first ordering, adopt-server-id, connect-now-on-foreground, close≠kill, oversized-replay terminal, gate-decision epoch drop.
- **A15** `Owns: :app/wiring/{EventBus,TerminalSessionController(interface+skeleton),AppEnvironment,RetainedSessionHolder,ColdStartPolicy}.kt + di/*Module.kt boundaries`. `Verify:` per-consumer `Channel` fan-out (slow collector never drops a gate AND never stalls output); engine hosted in `@ActivityRetainedScoped`/nav-scoped holder; contract compiles for all AW4 consumers.
- **A16** `Owns: :terminal-view/**`. `Verify:` cols×rows from font metrics; replay never drops bytes (pendingOutput flush, chunked off-main append); DECCKM arrows still `ESC O A` under app-cursor mode; rotation re-binds surviving emulator without blank/replay.
- **A21** `Owns: :app screens/TerminalScreen.kt, components/ReconnectBanner.kt, wiring/TerminalSessionController(impl).kt`. `Verify:` banner precedence (exited/failed outrank connecting/reconnecting); exit `-1` spawn-failure copy; `replayTooLarge` no-spinner actionable copy + "new session"; toolbar "new session in cwd" → `attach(null, cwd)`.
- **A27** `Owns: :app screens/ClientCertScreen.kt, viewmodels/ClientCertViewModel.kt`. `Verify:` SAF `.p12` import → AndroidKeyStore (import validates before persist so a bad passphrase can't clobber a prior identity); rotate/remove; summary (issuer-CN/expiry/expired-warning); host-menu reachability.
- **A29** `Owns: :app wiring/SessionActivityBridge.kt, components/ContinueLastBanner.kt, nav/ColdStartPolicy-wiring`. `Verify:` no-host→pairing / host→sessions route; adopted→set / exited→clear `LastSessionStore`; banner in stack + sidebar.
- **A30** `Owns: :app push/{FcmService,NotificationBuilder,DenyBroadcastReceiver,AllowTrampolineActivity}.kt`. `Verify:` Deny via `BroadcastReceiver` (`goAsync()` + expedited POST, no UI); Allow via translucent `excludeFromRecents` Activity hosting `BiometricPrompt` then POST; token never logged/persisted; single-use token → retried-after-success POST returns 403 (idempotency guarantee).
- **S1** `Owns: throwaway sandbox module (deleted after)`. `Verify:` a Termux `TerminalEmulator` with the JNI process removed renders one canned screen from WS-shaped bytes; sizes the `TerminalSession` replacement surface. **Gate to AW3.**
- **S2** `Owns: throwaway push sandbox + a test Firebase project`. `Verify:` measure data-only high-priority delivery latency/loss on ≥2 real handsets (incl. one Xiaomi/Huawei/Samsung) under Doze/force-stop. **Feeds the R1 decision.**
---
## 6. Terminal rendering deep-dive (the single hardest piece)
No JVM port of SwiftTerm exists, so we adopt **Termux `terminal-emulator` (VT parser + `TerminalBuffer` scrollback ring) + `terminal-view` (`TerminalView` + `TerminalRenderer`)**. Do NOT hand-roll an ANSI parser. **This is a source vendor, not a Maven dependency** — there is no consumable `com.termux:terminal-emulator` artifact, which is why R2 (license) and S1 (seam spike) gate AW3.
### 6.1 The wiring problem and the chosen shape (this is a fork, not a subclass)
Termux's `TerminalView.attachSession(TerminalSession)` couples the view to a `TerminalSession` whose **constructor forks a local process over JNI** and pumps its stdout into the emulator, and whose `write()`/`getEmulator()`/`initializeEmulator()` the view drives. We have no local process. So the real work is **forking/replacing `TerminalSession`** — remove the JNI subprocess, override `write()` to send over the WS, and call `initializeEmulator()` manually — **not merely subclassing the view** (platform review correction). S1 proves this seam is mechanically achievable before A16 commits 23 weeks.
```
class RemoteTerminalSession(
private val engineSend: (ClientMessage) -> Unit // ordered send pump → engine.send
) {
val emulator = TerminalEmulator(termOutput, cols=80, rows=24, transcriptRows=10_000)
// termOutput: TerminalOutput whose write(bytes,off,len) forwards to engineSend(Input)
// — carries emulator-originated bytes: DA/DSR replies, mouse, bracketed-paste wrappers.
fun feedRemote(bytes: ByteArray) // remote output → emulator.append (off-main, chunked); post onScreenUpdated()
fun writeInput(data: String) // typed/keybar bytes → engineSend(Input(data))
fun updateSize(cols, rows) // emulator.resize(cols,rows); engineSend(Resize)
}
```
The Termux `TerminalView` is subclassed only to (a) expose an `onKeyCommand`-style outlet and (b) install the key-bar / pointer menu — glyph rendering, cursor, selection, IME, scroll stay stock, exactly as iOS uses stock SwiftTerm.
### 6.2 Inbound: output bytes → rendered cells (off-main append)
```
SessionEngine.events (engine confinement dispatcher)
→ SessionEvent.Output(String) decoded to ByteArray (UTF-8) in TerminalSessionController (A21)
→ RemoteTerminalSession.feedRemote(bytes)
→ append on a CONFINED single-thread dispatcher (mirrors Termux's background reader thread),
CHUNKED so a multi-MB ring-replay never blocks a single append
→ post terminalView.onScreenUpdated() to Dispatchers.Main.immediate (invalidate → TerminalRenderer.render())
```
**Why off-main (platform review mustFix):** `TerminalEmulator`/`TerminalBuffer` is single-writer, read by the renderer during the UI-thread draw. A synchronous multi-MB `append` on `Main.immediate` blocks the UI thread → ANR. Termux appends on a background reader and only posts `onScreenUpdated` to the UI thread; we replicate that (single-writer discipline enforced against the UI-thread renderer read).
**pendingOutput invariant (must replicate exactly):** output that arrives before the `TerminalView` binds (ring-buffer replay can land first) is queued in an `ArrayDeque<ByteArray>` and flushed **in submission order** the instant the view binds. The `ESC[0m` soft-reset prefix on replay is passed through, never stripped.
### 6.3 Outbound: input → bytes → wire (one ordered pump)
Three sources funnel into a **single ordered send pump** (a `Channel<ClientMessage>` consumed by one coroutine) so two fast taps never race onto the wire:
1. **Typing (IME):** `TerminalView``RemoteTerminalSession.writeInput` (stock Termux `InputConnection` path, redirected from process-write to WS-send).
2. **Key-bar taps:** Compose buttons → `KeyByteMap.bytes(key)``engine.send(Input(...))` **bypassing the IME entirely** (soft keyboard never pops), mirroring the web `ws.send` bypass.
3. **Hardware chords:** `onKeyEvent` → Esc/Ctrl-letter/⇧Tab map via `KeyByteMap`; **arrows/Enter/Tab are left to Termux's `KeyHandler`** so `cursorKeysApplication` (DECCKM) still emits `ESC O A` for vim/htop. Hardcoding `ESC [ A` would break TUIs — do not.
### 6.4 Resize math (cols×rows)
Termux `TerminalRenderer` exposes cell metrics: `mFontWidth = ceil(paint.measureText("X"))`, `mFontLineSpacing = ceil(descent - ascent) + lineSpacingAdd`. On every layout / font-size change:
```
cols = max(1, floor((viewWidth - 2*hPad) / mFontWidth))
rows = max(1, floor((viewHeight - 2*vPad) / mFontLineSpacing))
if (cols,rows) != lastSentDims && cols>0 && rows>0:
emulator.resize(cols, rows) // local buffer reflow
engine.send(Resize(cols, rows)) // → server SIGWINCH
lastSentDims = (cols, rows)
```
**Latest-writer-wins sizing (v0.4):** drop non-positive pre-layout dims; remember `lastSentDims`; on `Lifecycle.State.RESUMED` (pane-show/device-switch) and window-focus, re-send `lastSentDims` via `engine.notifyForegrounded(dims)` to reclaim full-screen. Attach/detach never resize. Feed **both** lifecycle-RESUMED and the view's size-changed callback into the same connect-now+resize path (missing either breaks device-switch reclaim). Font metrics differ from SwiftTerm, so QA resize parity against web/iOS on the same device sizes (R5).
### 6.5 Scrollback, selection, links, titles
- **Scrollback:** local scroll via `TerminalBuffer` `transcriptRows` (~10 000). Authoritative history is the server's ~2 MB ring replayed on attach — the local transcript is just for scroll-up while connected.
- **Selection/copy:** Termux `TextSelectionCursorController` + Android `ActionMode``ClipboardManager`.
- **Links:** URL detection → `Intent(ACTION_VIEW)` with an **http/https-only allowlist**. OSC 52 host-clipboard writes are declined.
- **OSC 0/2 titles:** delegate → **`TitleSanitizer` (in `:session-core`, JVM-tested)** — strip bidi/zero-width, 256-char cap. Titles are attacker-influenced; render inert. Same sanitizer feeds the session-list/switcher.
### 6.6 Lifecycle & config-change survival (retained holder — load-bearing for A14/A16/A21)
**Decision (architecture + platform mustFix):** `SessionEngine` + `RemoteTerminalSession` (emulator + scrollback) live in a **config-surviving `RetainedSessionHolder`** (`@ActivityRetainedScoped` / nav-scoped `ViewModel`, in `viewModelScope`), **not** `lifecycleScope` (which cancels at `ON_DESTROY` on every rotation). Rules:
- Distinguish **config change** (rotation/fold/multi-window/dark-mode — Activity recreated, Compose tree disposed, `AndroidView` factory re-run) from **real backgrounding** using `isChangingConfigurations()` / `onCleared()`.
- On **config change:** the holder survives; **re-bind the surviving emulator to a recreated `AndroidView`** — no detach, no ~2 MB replay round-trip, no scroll-position loss. (Prefer this over `android:configChanges`, which breaks resource re-resolution and posture changes.)
- On **real background** (`ON_STOP` that is not a config change): `engine.close()` (clean detach; server PTY survives). On real foreground return after a background stop: rebuild the stack, bump `generation`, key the `AndroidView` by `generation` so a fresh emulator replays the ring and re-fires resize. The generation bump is reserved for **genuine background detach only**, never config change.
- **Flow-collection scoping:** all UI-facing collectors use `collectAsStateWithLifecycle`/`repeatOnLifecycle(STARTED)`; the HTTP poll is a STARTED-scoped loop (matching iOS's foreground poll); the WS is gated to STARTED; the terminal output collector runs on `Main.immediate` but is cancelled with the **retained holder**, not the composition, so a recompose never drops a frame.
### 6.7 Preview thumbnails (cleaner than iOS, but more code — re-baselined M→ML)
Instantiate an **off-screen `TerminalEmulator`** (no `TerminalView`), `append(previewBytes)`, then rasterize `TerminalBuffer` cells directly with a `Canvas` + monospace `Paint` — port `TerminalRenderer.render` onto a `Bitmap`-backed `Canvas`, drawing fg/bg per cell from each cell's `TextStyle`. This avoids iOS's off-screen-`UIView` snapshot and the `CADisplayLink` leak, but the manual per-cell painter is **more work than iOS's cheap `SwiftTerm.feed` off-screen path** — sized ML, on the A16 critical path. Cache `Bitmap` in an `LruCache` keyed **`(sessionId, lastOutputAt)`** (unchanged `lastOutputAt` ⇒ never re-render); cap concurrency with `Semaphore(2)` (FIFO); dedup same-key via `Map<Key, Deferred<Bitmap>>` under a `Mutex`; any failure caches a placeholder under the same key. Fetch preview bytes over the **mTLS OkHttp client** (tunnel-host previews must pass nginx).
### 6.8 Fallback if the primary renderer fails
Two tiers, chosen by decision gate. **Note the blast radius:** the tier-2 WebView path also invalidates the §6.7 off-screen-emulator thumbnail approach, so it is a partial AW3 rebuild, not a drop-in swap.
1. **License-safe fallback (if Termux GPL/AGPL is rejected, §9 R2):** fork **jackpal `Android-Terminal-Emulator`** (Apache-2.0). Weaker true-color/OSC coverage — accept a fidelity QA pass. Same `RemoteTerminalSession` seam applies.
2. **Fidelity fallback (if JVM emulation diverges from web/iOS on real Claude TUIs):** render **xterm.js offscreen in a `WebView`** — byte-identical to the web client by construction; feed `output` via `evaluateJavascript("term.write(...)")`, read input via a JS bridge. Heavier (WebView per session), against the native goal — **last resort**, decided after A16 QA against live Claude Code sessions (and would require re-doing §6.7 thumbnails).
### 6.9 Server-cert trust (closes the platform-review gap)
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 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.
---
## 7. Testing strategy (mirror iOS package tests; 80% coverage target)
### Unit (JVM, no device) — the 80% target modules
JUnit5 + `kotlinx-coroutines-test` (virtual time) + Turbine + MockK. Measured by **Kover**; gate ≥80% on `:wire-protocol`, `:session-core`, `:api-client`, `:host-registry` (logic), **and `:client-tls` pure half**.
- **`:wire-protocol`** — `MessageCodec` **golden-vector** byte-equality vs captured web-client frames (explicit `sessionId:null`, top-level `approve.mode`, JS control-char escaping, lowercase UUIDs); tolerant decode (bad JSON/unknown type/missing-required → null; wrong-typed optional → absent; `telemetry.at` mandatory); `Validation` (v4 regex; resize 1..1000; absolute cwd); `HostEndpoint` origin/wsURL vs `src/http/origin.ts` (default-port omission, lowercase, IPv6 brackets).
- **`:session-core`** — `ReconnectMachine` ladder + no-reset-on-foreground; `PingScheduler` 25s/2-miss (drives only `PingableTermTransport`); `GateTracker` rising-edge epoch + two-line stale-guard + `canDecide`; `AwayDigest` once-per-reconnect; `UnreadLedger` watermark/cap/tie-break; **`TitleSanitizer` bidi/zero-width strip + 256 cap**; `SessionEngine` (FakeTransport + FakeTimeSource): attach-first ordering, adopt-server-id, connect-now-on-foreground, close≠kill, oversized-replay terminal, gate-decision epoch drop.
- **`:api-client`** — FakeHttpTransport: **Origin-iff-guarded** (3 guarded routes carry byte-equal Origin; RO GETs must not); tolerant list decode (drop-one-keep-rest, unknown status→unknown, non-array→invalidResponseBody); **strict RFC3986 query encoding**; UiPrefs unknown-key round-trip (Int-vs-Double preserved); loose FCM-token validator; pairing probe leaves no orphan; `PairingError`/`HostClassifier` tiers (fail-safe unknown→public).
- **`:client-tls` pure half** — PKCS12 parse happy + wrong-passphrase (`UnrecoverableKeyException`) + corrupt (`EOFException`); `X509KeyManager` alias/`getPrivateKey` selection; `CertificateSummary` fields; classifier warning tiers.
### Instrumented (androidTest — device/emulator; Robolectric only where faithful)
- **`:terminal-view`** — resize→cols/rows for a known font size; keybar tap → exact bytes on a fake send pump; DECCKM arrow form; pendingOutput flush ordering; **off-main chunked append does not stall UI**; rotation re-binds surviving emulator (no blank); thumbnail rasterization non-null.
- **`:client-tls` framework half (real AndroidKeyStore — NOT Robolectric)** — import happy + no-identity mapping; **re-reading `X509KeyManager` presents a mid-run-imported cert on the next handshake with no relaunch**; **`connectionPool.evictAll()` — pooled/resumed connections do not reuse the old identity**; import validates before persist (bad passphrase can't clobber prior identity).
- **`:host-registry`** — DataStore read-modify-write list; secret split (cert never in plain store).
- **`:app`** — FCM data-only → `NotificationBuilder` Allow/Deny actions; **Deny `BroadcastReceiver` POSTs `/hook/decision` (token never logged)**; **Allow trampoline Activity hosts a mock `BiometricPrompt` then POSTs**; DeepLinkRouter v4-whitelist; Compose UI tests for gate banner/plan sheet/**reconnect banner precedence**/session-list rows/**continue-last banner**.
### E2E / integration (mirror `ios/IntegrationTests`, ~10 tests)
Instrumented suite against a **real `npm start` Node server**: `attach→attached→output` timing; reconnect replays ring buffer (F5/F6); spawn-failure → `exit(-1)`; kill via `DELETE /live-sessions/:id`; `hook/decision` resolves a held gate; bad Origin rejected (F9). Plus **one macrobenchmark/Espresso happy-path**: pair → attach → type → approve.
### Deferred to real device (matches iOS)
Gesture/IME/CJK composition, camera-QR, haptics, lock-screen Allow/Deny, FCM end-to-end (real Firebase + device), OEM battery-manager + **force-stop** delivery — a written manual checklist in A36 (informed by the S2 spike), not automated.
---
## 8. Security checklist (non-negotiable)
- [ ] **Origin header parity** — one `HostEndpoint`-derived `Origin` (frozen in `:wire-protocol`), byte-equal to `new URL()`; stamped on the WS handshake and the **3 guarded HTTP routes** only. The single non-skippable CSWSH defense (TECH_DOC §7). Verified vs `src/http/origin.ts`.
- [ ] **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.
- [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.
- [ ] **App Links**`assetlinks.json` over HTTPS, no redirect, correct **release** signing-cert SHA-256; deep-link fields UUID-whitelisted, invalid → ignore+count (never partially applied).
- [ ] **Untrusted server strings** — titles/paths/branches/diff lines/telemetry/gate-detail rendered as **inert Compose `Text`**, no Markdown/`linkify`/`AnnotatedString` autolink; PR badge is a tappable link only when the URL parses `https`. OSC titles routed through `TitleSanitizer`.
- [ ] **No secrets in code** — FCM service-account JSON **server-side only**; `google-services.json` (client config, not a secret) is fine; no API keys hardcoded; all config via env/DI.
- [ ] **`POST_NOTIFICATIONS`** requested with rationale (API 33+); **`FLAG_SECURE` + privacy shade** so the recents snapshot never leaks terminal bytes. **No `NEARBY_WIFI_DEVICES`** — Android has no iOS-style local-network permission prompt; plain WS to a LAN IP needs no runtime permission (R12).
---
## 9. Risks & open questions (ranked)
| # | Risk / question | Severity | Mitigation / decision |
|---|---|---|---|
| **R1** | **FCM has no server-defined action buttons**; data-only high-priority is the only faithful Allow/Deny reproduction, and delivery is best-effort (Doze; OEM battery killers on Xiaomi/Huawei/Samsung; **a force-stopped/swiped-away app may get no FCM until next launch**; normal-priority "done" is Doze-batched). | **Accepted (was Critical)** | **DECIDED (user): accept best-effort background delivery** — documented as a known limitation; no background FGS (R9). Gate = `priority:"high"` data-only, notification built on-device; "done" = `normal`. S2 spike still runs to quantify real-device loss and inform user-facing copy ("delivery not guaranteed while backgrounded"). |
| **R2** | **Termux `terminal-emulator`/`terminal-view` licensing.** ~~GPL, must vendor~~ **CORRECTED (independently verified 2026):** these two libraries are **Apache-2.0** (per Termux `LICENSE.md` — they descend from jackpal's Apache-2.0 emulator; only `termux-shared` + the app module are GPLv3) and are **consumable via JitPack** (`com.termux:termux-app:terminal-view:0.118.0`), so no copyleft on the app and vendoring is optional. | **Low (was Critical)** | **No longer a blocking legal decision.** Mitigation = **scope the dependency to `terminal-view`(+`terminal-emulator`) only; never pull `termux-shared`/app module** (GPLv3). Add `com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava` to dodge a Guava conflict. When vendoring for version-pinning, spot-check per-file Apache headers. jackpal fork is a needless fallback (Apache-2.0 but archived 2022, VT-100-only). Sources: Termux `LICENSE.md`, Termux-Libraries wiki. |
| **R3** | **Byte-exact JSON encode parity** vs `JSON.stringify`. | High | Hand-rolled `StringBuilder` codec + golden-vector tests (A4). Non-negotiable gate. |
| **R4** | **OkHttp binds `SSLSocketFactory` at build time** + pooled/resumed connections keep the old identity → breaks "no-relaunch" rotation. | High | Custom re-reading `X509KeyManager` over the AndroidKeyStore identity + `connectionPool.evictAll()` on rotation. Instrumented test proves next-handshake cert + no old-identity reuse (A11). |
| **R5** | **cols×rows drift** — Android font metrics ≠ SwiftTerm. | High | Derive metrics from `TerminalRenderer` exactly; unit-test the formula; QA resize parity vs web/iOS. |
| **R6** | **Oversized-replay classification** — OkHttp has no default max message size. | Medium | Client-side 16 MiB frame cap → non-retryable `replayTooLarge`; spike a real large-scrollback session. |
| **R7** | **Server OAuth2 for FCM** — RS256 JWT + refresh vs the zero-new-dep rule. | Resolved | **DECIDED (user): use `google-auth-library`.** Add it as a server dependency (a deliberate exception to the zero-new-dep ethos) so A33 doesn't hand-roll RS256/JWKS. |
| **R8** | **Key-bar above IME** — no `inputAccessoryView`. | Medium | Compose overlay via `WindowInsets.ime`/`imePadding()`; QA multiple IMEs; taps never route through `BasicTextField`. |
| **R9** | **Background WS vs Android 12+ FGS-background-start block and the Android 14 `dataSync` / Android 15 `mediaProcessing` 6h-per-24h cap** (the draft's "6h FGS cap" was imprecise). No clean `foregroundServiceType` for "hold a WebSocket" (`dataSync` is capped; `specialUse` needs Play review; `connectedDevice` needs a companion). | Medium | **Decision: drop the background foreground-service entirely.** WS is foreground-only (STARTED-scoped); **FCM high-priority = background wake → foreground reconnect + replay**; server keeps the PTY alive. Avoids `specialUse` Play review and any `startForeground()` crash. |
| **R10** | **EventBus fan-out** — a single `SharedFlow` with `onBufferOverflow=SUSPEND` back-pressures the engine pump (head-of-line-blocks OUTPUT for every consumer); DROP could lose a gate. | Medium | **Per-consumer buffered `Channel`s** (fan-out coroutine offers to each) — the true analogue of iOS's per-subscriber `AsyncStream`; a slow collector neither drops a gate nor stalls output. Tested in A15. |
| **R11** | **`/prefs` unknown-key clobber**. | Medium | Store the raw `JsonObject`, rewrite only known keys; never PUT if `/prefs` never loaded; round-trip test (unknown key + Int-vs-Double). |
| **R12** | **Error-taxonomy mismatch** — Android exceptions don't map 1:1 to NSURLError/POSIX; **no local-network permission prompt on Android** (iOS `localNetworkDenied`/`NEARBY_WIFI_DEVICES` logic is dead weight). | Low | Re-derive `PairingError.classify` from `ConnectException`/`SSLHandshakeException`/`UnknownHostException`; **drop `NEARBY_WIFI_DEVICES` and `localNetworkDenied`** (they gate Wi-Fi scanning/Aware, not sockets). |
| **R13** | **Tablet detection fuzziness** — no clean iPad boolean. | Low | `currentWindowAdaptiveInfo().windowSizeClass`; pointer menu = `Modifier.pointerInput` secondary-click → `DropdownMenu`, gated on `Expanded` + `smallestScreenWidthDp>=600`. |
---
## 10. Effort estimate & suggested build order
Sizes carry over the iOS subsystem estimates, re-scaled for the port (S≈12d, M≈35d, L≈11.5wk, XL≈23wk, one engineer-equiv; parallelizable within a wave). Calendar re-baselined per risk review.
| Wave | Tasks | Effort | Notes |
|---|---|---|---|
| **AW0** foundations | A1A3 | **SM** (24d) | Serial; unblocks everything. `HostEndpoint` frozen here. |
| **AW1** leaf modules + spikes | A4A13 + S1 + S2 | **XL total, parallel** (~1.5wk wall) | Highest fan-out; 45 agents. A10+A11 (client-tls split) is the security-critical long pole. **S1/S2 de-risk the two Criticals now.** |
| **AW2** engine + wiring freeze | A14 (L), A15 (M) | **L** (~1.5wk) | Actor→structured-concurrency lifecycle + retained holder is the correctness risk; **A15 must freeze the wiring/DI seam before AW4.** |
| **AW3** terminal render | A16 (XL), A17 (M), A18 (ML) | **XL** (~2.53wk) | The dominant uncertainty; **S1 green first** (R2 license already resolved — Apache-2.0/JitPack). A17/A18 parallel after A16's seam lands. |
| **AW4** UI screens | A19A29 | **XL total, highly parallel** (~22.5wk wall) | File-disjoint by screen → 45 agents. A20+A18 thumbnail, A23 grouping-parity, A21 banner/lifecycle are the risky ones. |
| **AW5** push/mTLS/links | A30 (L), A31 (S), A32 (M), A33 server (M) | **LXL, mostly parallel** (~1.5wk) | A33 (server FCM) startable day 1; A30/A31 pull forward alongside AW2/AW3 (depend only on AW1). |
| **AW6** integration/acceptance | A34A36 | **M** (~1wk) | E2E vs real Node server + coverage gate + device-QA checklist. |
**Total:****1114 engineer-weeks** solo; ≈ **710 calendar weeks** with 35 parallel agents per wave. The serial spine AW0 → A14/A15 → A16 → A21 → AW6 floors wall-clock at ~7 weeks even with infinite agents — the A14→A16 chain cannot be parallel-compressed, so the draft's "57 weeks" low end is not achievable.
### Suggested build order (critical path bolded)
1. **AW0 A1→A2→A3** (serial). Simultaneously kick off **A33** (server FCM — independent) and stand up **S1** and **S2** sandboxes.
2. **AW1** in parallel: **A4** (codec golden vectors) and **A7** (transport) are on the critical path; A5, A6, A8, A9, A10, A11, A12, A13 fill the batch. **Run S1 (renderer seam) and S2 (FCM delivery) here.**
3. **All three open questions are now DECIDED** — R2 (Termux = Apache-2.0/JitPack), R7 (use `google-auth-library`), R1 (accept best-effort FCM delivery). Nothing blocks AW3 or AW5. S2 still runs to quantify FCM loss and drive user-facing "not guaranteed while backgrounded" copy.
4. **AW2 A14** (engine) then **A15** (wiring/DI freeze) — everything terminal/UI waits on the `SessionEvent` contract and the frozen wiring seam.
5. **AW3 A16** (renderer) — the XL spike; get it green against a live Claude Code session (decide R5 cols×rows fidelity) before A17/A18. Give it explicit schedule buffer. (No R2 license fallback needed — Termux terminal libs are Apache-2.0.)
6. **AW4** — fan out the screens: land **A21** (terminal wiring + reconnect banner) + **A22** (gate) first (full engine→UI path), then discovery/projects/diff/quick-reply/timeline/cold-start/cert, then A26 large-screen last. **Start A19/A23/A24/A25 during AW3** (they depend only on AW1 modules) to reclaim wall-clock lost to the serial A14→A16 spine.
7. **AW5** — pull A30/A31 forward alongside AW3; A32 deep links after A21; needs A33 on a test Firebase project for end-to-end.
8. **AW6** — integration, coverage gate, README, deferred device-QA checklist.
---
**Key file references (authoritative context):** `docs/TECH_DOC.md` §4/§5.2/§7, `docs/ARCHITECTURE.md` §1/§3.2/§8, `ios/README.md` + `ios/Packages/*`, and the confirmed server route surface in `src/server.ts` (`src/http/*`, `src/push/*`). The frozen wire contract lives in `src/types.ts` / `src/protocol.ts`, mirrored by the Android `:wire-protocol` module (A2).
---
## 11. Review resolutions
### Architecture review (7.5) — what changed
- **mustFix (Allow ≠ BroadcastReceiver):** Allow now routes through a translucent `excludeFromRecents` trampoline `Activity` hosting `BiometricPrompt`; Deny stays a `BroadcastReceiver`. §5 A30, §8, §1 P1 updated.
- **mustFix (engine ownership / config-change survival):** committed to a `RetainedSessionHolder` (`viewModelScope`/`@ActivityRetainedScoped`), distinguishing config change from real background via `isChangingConfigurations()`/`onCleared()`, re-binding the surviving emulator on rotation; generation-bump reserved for genuine background. §6.6 rewritten; A14/A15/A16/A21 gated on it.
- **Adopted:** `TitleSanitizer``:session-core` (A6); `:client-tls` split into pure + framework halves (A10/A11, pure half in the Kover gate); `:terminal-view` boundary pinned to `:wire-protocol` only (decode in A21); **per-consumer `Channel` EventBus** (R10, A15); expedited decision POST not `WorkManager` (A30, idempotency via single-use token); confinement contract written as invariant #4; "1:1 mirror" reframed; Flow scoping (`repeatOnLifecycle(STARTED)`) specified (§6.6).
### Feature-parity review (7.5) — gaps closed
- **Reconnect/EXIT banner** → A21 (with precedence + spawn-failure/`replayTooLarge` copy + "new session" action).
- **Activity-timeline sheet** → A28 (wired to away-digest expand).
- **Device-cert management screen** → A27 (split out of pairing; host-menu reachable).
- **New-session-in-cwd** (phone toolbar + exit-banner) → A21.
- **Continue-last-session cold-start** (`ColdStartPolicy` + banner + `LastSessionStore` lifecycle) → A29.
- **Adopted:** `TitleSanitizer` named in A6; `DELETE /live-sessions` kill-all marked out-of-parity (§1 DEFERRED, §4.3/§4.4); host-menu contents enumerated (A20); iPad Projects grid assigned (A23); `lastOutputAt` noted as already server-serialized (A20 unblocked).
### Kotlin/Compose platform review (7) — corrections applied
- **mustFix:** Allow-in-receiver contradiction resolved (as above); **single mTLS key home** chosen (AndroidKeyStore-imported + custom X509KeyManager; PKCS12 KeyStore is import-parse-only; no persisted `.p12`); **Termux is a source fork/vendor** (S1 spike + R2), not a subclass; **FGS dropped** — FCM-wake→foreground reconnect (R9), "6h cap" corrected to `dataSync`/`mediaProcessing` 6h-per-24h.
- **Adopted:** off-main chunked `emulator.append` (§6.2); `connectionPool.evictAll()` on rotation (§8, A11); server-cert trust specified (§6.9); per-consumer channels (R10); `NEARBY_WIFI_DEVICES`/`localNetworkDenied` removed (R12); `ContextMenuArea``pointerInput` `DropdownMenu` (R13, A26); `EncryptedFile` inconsistency removed (Tink only, §2); loose FCM-token validator (§4.5); force-stop FCM caveat (R1).
### Risk & sequencing review (7) — sequencing fixes
- **mustFix:** `HostEndpoint`/Origin moved to the AW0 freeze (A2); an explicit **wiring+DI freeze (A15)** inserted at the top of the AW2→AW4 boundary; a **throwaway renderer spike (S1)** added to AW1 before AW3 commits.
- **Adopted:** **S2** FCM delivery spike in AW1; A30/A31 pulled forward; A5/A7 split into finer tasks (A5/A6 and A8/A9); A18 thumbnail re-baselined M→ML; calendar re-baselined to ~710 weeks; A19/A23/A24/A25 started during AW3.
### Post-review correction (independent verification)
- **R2 downgraded Critical→Low.** A dedicated web-verification pass (after the two explorer agents for the terminal stack failed their structured-output step) established that Termux's `terminal-view` + `terminal-emulator` are **Apache-2.0** (not GPL) and **available on JitPack** (not vendor-only). The plan's worst-case "choose GPL / fork / open-source" framing was wrong; the renderer decision is de-risked and AW3 is no longer gated on a license call. §2, §9 R2, §5, §10, and open-question #1 updated accordingly. Only guardrail: keep the dependency scoped to those two libs (never `termux-shared`/app module, which are GPLv3).
### Decisions locked (no open questions remain)
1. ~~**R2 (BLOCKS A16 / AW3):** Termux is GPL and must be vendored.~~ **RESOLVED (verified 2026).** `terminal-view`/`terminal-emulator` are **Apache-2.0** on **JitPack**; no copyleft as long as the dependency is scoped to those two libs (not `termux-shared`/app). AW3/S1 not gated on a license call. Residual technical-only choice: JitPack-consume vs vendor-pin → default JitPack `0.118.0`.
2. **R1 (push reliability): ✅ ACCEPTED (user).** Best-effort FCM background delivery is accepted as a documented limitation (no background FGS). S2 spike still runs to quantify loss and drive user-facing "delivery not guaranteed while backgrounded" copy.
3. **R7 (FCM OAuth2): ✅ DECIDED (user) — use `google-auth-library`.** Added as a server dependency (deliberate exception to zero-new-dep); A33 does not hand-roll RS256.