Files
web-terminal/docs/ANDROID_CLIENT_PLAN.md
Yaojia Wang 34e4a88059 docs: Android client implementation plan (multi-agent explore + review)
Phased build spec for an Android client at functional parity with the iOS
client. Produced by a multi-agent workflow: 10 iOS-subsystem explorers + 2
Android-tech researchers -> architect synthesis -> 4-lens adversarial review
-> finalize. Mirrors the iOS SPM package set as Gradle modules; 36 tasks
across waves AW0-AW6 with Owns/Depends/Verify, two de-risking spikes.

R2 (Termux license) independently re-verified and downgraded Critical->Low:
terminal-view/terminal-emulator are Apache-2.0 and on JitPack, not GPL-vendor.

Open questions for the user: R1 (FCM background-delivery acceptance),
R7 (FCM OAuth2 dependency).
2026-07-08 11:31:30 +02:00

71 KiB
Raw Blame History

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, TECH_DOC.md (§4 protocol, §5.2 session model, §7 security), ARCHITECTURE.md (invariants §8), and the multi-agent 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 Channels). 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" pageiOS 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-coroutinesDispatchers.Default.limitedParallelism(1) for engine confinement; per-consumer Channels 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-protocolRemoteTerminalSession 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 pureSessionEngine, 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 (RS256-signed service-account JWT → oauth2.googleapis.com/token, ~1h bearer cached like the APNs JWT) → 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; refresh token on 401. Default: hand-roll RS256 + HTTPS to honor the repo's zero-new-dep ethos (matching apns.ts); adding google-auth-library is an option flagged in R7.
  • 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): TerminalViewRemoteTerminalSession.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).

  • 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 ActionModeClipboardManager.
  • 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 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-protocolMessageCodec 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-coreReconnectMachine 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 postureusesCleartextTraffic=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 gaterunProbe 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 splitDeny → 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 PendingIntents 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 Linksassetlinks.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). Critical Gate = priority:"high" data-only, notification built on-device; "done" = normal. S2 spike measures real-device delivery early. Open question (user): accept weaker background-delivery guarantees (documented best-effort), given the FGS decision in R9.
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. Medium Default: hand-roll RS256+HTTPS (matches apns.ts). Open question (user, low-stakes): allow google-auth-library instead.
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 Channels (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. Resolve R7 (FCM OAuth2) now — before AW5 commits. (R2 Termux license is already resolved: Apache-2.0 via JitPack, scope to terminal-view/terminal-emulator only.) Feed S2 results into the R1 decision.
  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); ContextMenuAreapointerInput 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).

Open questions that genuinely need the user's decision

  1. R2 (BLOCKS A16 / AW3): Termux is GPL and must be vendored. RESOLVED (verified 2026) — no longer a user decision. terminal-view/terminal-emulator are Apache-2.0 and available on JitPack; no copyleft on the app as long as the dependency is scoped to those two libs (not termux-shared/app). AW3/S1 are no longer gated on a license call. Only residual (technical, not a blocker): JitPack-consume vs vendor-pin — default to JitPack 0.118.0, vendor only if you need a pinned commit.
  2. R1 (push reliability): Accept best-effort background delivery for FCM data-only high-priority (Doze / OEM battery killers / force-stop), given the decision to run no background foreground-service? The S2 spike will quantify it, but the acceptance call is yours.
  3. R7 (low-stakes): Server FCM OAuth2 — hand-roll RS256 to honor the repo's zero-new-dep ethos (default), or permit google-auth-library?