# 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 (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).
- **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** 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. |
| 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).

### 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 ~3–5 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]  (M–L)
        │
        ▼
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 2–3 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→M–L)

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 M–L, 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 only for private LAN/Tailscale CIDRs via `network_security_config` (global `usesCleartextTraffic=false`).
- **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.
- [ ] **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").
- [ ] **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≈1–2d, M≈3–5d, L≈1–1.5wk, XL≈2–3wk, one engineer-equiv; parallelizable within a wave). Calendar re-baselined per risk review.

| Wave | Tasks | Effort | Notes |
|---|---|---|---|
| **AW0** foundations | A1–A3 | **S–M** (2–4d) | Serial; unblocks everything. `HostEndpoint` frozen here. |
| **AW1** leaf modules + spikes | A4–A13 + S1 + S2 | **XL total, parallel** (~1.5wk wall) | Highest fan-out; 4–5 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 (M–L) | **XL** (~2.5–3wk) | 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 | A19–A29 | **XL total, highly parallel** (~2–2.5wk wall) | File-disjoint by screen → 4–5 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) | **L–XL, mostly parallel** (~1.5wk) | A33 (server FCM) startable day 1; A30/A31 pull forward alongside AW2/AW3 (depend only on AW1). |
| **AW6** integration/acceptance | A34–A36 | **M** (~1wk) | E2E vs real Node server + coverage gate + device-QA checklist. |

**Total:** ≈ **11–14 engineer-weeks** solo; ≈ **7–10 calendar weeks** with 3–5 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 "5–7 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→M–L; calendar re-baselined to ~7–10 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.