The server half shipped 2026-07-30 as feature A (4892fa7/f6ef19e/d39a0ab) and both native clients are at zero. This is NOT a regression — OrphanSessionInfo is deliberately a separate type rather than a flag on LiveSessionInfo, and src/server.ts:600 names the reason: the clients decode /live-sessions, so a variant shape there would have broken them. It is worth planning anyway because SessionManager.shutdown only detaches the tmux client for tmux-backed sessions (manager.ts:410-417), so a server restart or a desktop-app quit turns every session into an orphan. The web launcher lists them and re-adopts on tap; the phone still shows an empty list — which is the walk-away case this product exists for. The plan doc carries the eleven traps a client implementer will hit, each one a shipped review finding or a documented server subtlety — notably that preview returns cols/rows of literally 0 (both clients currently size thumbnails from that response), that a 429 must not collapse to "no orphans", and that the destructive confirmation must come from count-idle rather than card count (that one nearly ended 33 shells while the dialog said 24). Also corrects two now-false claims found while cross-referencing: the ROADMAP row for tmux discovery was half-stale, and ANDROID_CLIENT_PLAN's do-not-consume list still listed POST /projects/worktree (both clients consume it now) and GET /sessions (iOS consumes it, Android does not — so "match iOS" now argues for building it, not against).
73 KiB
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 toios/README.md,TECH_DOC.md(§4 protocol, §5.2 session model, §7 security),ARCHITECTURE.md(invariants §8), and the multi-agentPLAN.md. Same working discipline as the rest of the repo: phased waves, file-disjoint tasks with stable IDs andOwns:lists for multi-agent parallelism, TDD, progress tracked inPROGRESS_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
URLSessionTermTransportinsideSessionCoreandURLSessionHTTPTransportinApp/Wiring) are consolidated into:transport-okhttpso:session-corebecomes genuinely pure and runs underrunTestvirtual time; (2) a:terminal-viewmodule 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.
- 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.datais raw keyboard bytes passed through unfiltered (ARCHITECTURE invariant #1, #9). - PTY lifecycle ≠ WebSocket lifecycle. A WS disconnect must NEVER kill the server PTY;
close()== detach. Sessions are keyed bysessionId; 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. - Origin-header validation on the WS handshake is the one defense that cannot be skipped (CSWSH, TECH_DOC §7). The
Originstring must byte-match the browser'snew 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. - Confinement contract (the actor analogue). Engine state lives only on the engine's
limitedParallelism(1)dispatcher; emulator/scrollback state lives only onDispatchers.Main.immediate. The only cross-boundary calls areengine.send()(UI→engine) and the event fan-out (engine→UI, per-consumerChannels). 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-sessionskill-all / a "manage" page — iOS does NOT consume this route; its swipe-to-kill and pointer-menu kill both use per-sessionDELETE /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-
KeyChainMDM cert path — documented fallback only; wrong trust model for a per-device pinned identity. - Self-signed-LAN
wss://custom trust / pinning — bare LAN usesws://cleartext (permitted inbase-config; see §6.9 — the platform has no CIDR syntax, so it CANNOT be scoped to RFC1918);wssis only for the tunnel/Tailscale, which present real CA-signed certs. ACertificatePinner/customX509TrustManagerpath 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 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, permitted in network_security_config's base-config — the format has NO address-range syntax, so it cannot be narrowed to LAN literals (§6.9). |
Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. |
| QR scan | CameraX + ML Kit Barcode (com.google.mlkit:barcode-scanning) |
On-device, one validator shared with manual entry. |
| Biometric | androidx.biometric:BiometricPrompt hosted in a FragmentActivity (the Allow trampoline) |
Gates the Allow action. |
| Testing | JUnit5 + kotlinx-coroutines-test (virtual time) + Turbine + MockK (unit); Espresso + Compose UI Test (instrumented; Robolectric only where it faithfully emulates); Kover (coverage) | Mirrors iOS's injected-clock/fake-transport discipline. AndroidKeyStore/Tink tests are instrumented on a real emulator/device — Robolectric does not provide the AndroidKeyStore provider (platform review). |
3. Module / package architecture (mirrors the iOS package set)
Gradle multi-module. Mapping to the iOS packages, with the two additions noted in the framing block.
iOS SPM package Android Gradle module Kind
──────────────────────────────────────────────────────────────────────────────
WireProtocol → :wire-protocol pure Kotlin/JVM (testable)
(HostEndpoint here) — Origin derivation frozen in the contract
SessionCore (reducers) → :session-core pure Kotlin/JVM (testable)
— incl. TitleSanitizer (pure, security-relevant)
URLSessionTermTransport→ :transport-okhttp JVM (OkHttp) — impls interfaces
URLSessionHTTPTransport→ :transport-okhttp (relocated here from iOS App/Wiring)
APIClient → :api-client pure Kotlin/JVM (testable)
HostRegistry → :host-registry Kotlin + DataStore (storage behind iface)
ClientTLS → :client-tls SPLIT: pure half (JVM) + framework half
(SwiftTerm host view) → :terminal-view Android-framework-bound (Termux wrap)
TestSupport → :test-support pure Kotlin/JVM (fakes)
App/WebTerm → :app Android app (Compose, Hilt, FCM)
Dependency graph (arrows = "depends on"; nothing points upward)
:app (Compose UI, ViewModels, DI, FCM, DeepLinkRouter, DesignSystem, EventBus)
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls (framework half)
│ │ │ │ │
│ │ │ │ ▼
│ │ │ │ :client-tls (pure half)
└───────┬───────┴────────┴──────┬───────┴───────────────┘
▼ ▼
:wire-protocol ◀────── :transport-okhttp
(models, HostEndpoint, (impls TermTransport + HttpTransport,
TermTransport/HttpTransport, consumes ClientIdentityProvider)
PingableTermTransport)
▲
└──────── :test-support (FakeTransport / FakeHttpTransport / FakeTimeSource) → test source sets only
Boundary note (:terminal-view): :terminal-view depends only on :wire-protocol — RemoteTerminalSession consumes ClientMessage and raw ByteArray. The SessionEvent → ByteArray decode happens in :app's TerminalSessionController (A21), not in the terminal module. There is no :terminal-view → :session-core edge (draft ambiguity resolved per architecture review — A16 depends on the wiring freeze, not on the engine module).
Rules (mirroring ARCHITECTURE §1 "dependencies only flow down"):
:wire-protocolis the frozen shared contract — the Android analogue ofsrc/types.ts+ WireProtocol. It ownsClientMessage/ServerMessagesealed 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 interfacesTermTransport/HttpTransport/PingableTermTransport(a sub-interface so the purePingSchedulerdrives a ping only through transports that support it, matching iOS'stransport as? any PingableTermTransport). No Android imports. New wire types are added here only (coordination point).:session-coreis pure —SessionEngine,ReconnectMachine,PingScheduler,GateTracker/GateState,AwayDigest,UnreadLedger,KeyByteMap,TitleSanitizer,SessionEvent. Depends only on:wire-protocol; consumesTermTransportby interface — never touches OkHttp/Android; runs entirely underrunTestvirtual time.TitleSanitizerlives 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-okhttpimplementsTermTransport/PingableTermTransport(WS) andHttpTransport(REST). It takes an injectedClientIdentityProvider(from:client-tls) so mTLS is wired without:session-coreknowing about it.:client-tlsis split like:host-registry— a pure Kotlin/JVM half (PKCS#12 structural parse viaKeyStore("PKCS12"),X509KeyManageralias/getPrivateKeyselection logic,CertificateSummaryparsing,PairingError/HostClassifierwarning-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-testsPKCS12Importer/CertificateSummary/MutualTLSChallengeResponder) and avoids leaning on Robolectric for AndroidKeyStore.
Pure-Kotlin (JVM-unit-testable, the 80%-coverage targets): :wire-protocol, :session-core, :api-client, the logic half of :host-registry, and the pure half of :client-tls.
Android-framework-bound (instrumented + device QA): :terminal-view, the framework half of :client-tls, the storage half of :host-registry, and :app.
:app internal package layout (mirrors App/WebTerm/)
android/app/src/main/java/wang/yaojia/webterm/
├── di/ Hilt modules (OkHttp singleton, engine factory, stores, per-feature boundaries)
├── designsystem/ Theme.kt, Tokens.kt, StatusBadge, TelemetryChip, Card…
├── screens/ PairingScreen, SessionListScreen, TerminalScreen, ProjectsScreen,
│ ProjectDetailScreen, DiffScreen, ClientCertScreen, TimelineSheet
├── viewmodels/ SessionListViewModel, GateViewModel, ProjectsViewModel, ProjectDetailViewModel,
│ DiffViewModel, PairingViewModel, ClientCertViewModel, TimelineViewModel
├── wiring/ TerminalSessionController, EventBus (per-consumer channels), SessionActivityBridge,
│ ThumbnailPipeline, ColdStartPolicy, AppEnvironment (composition root),
│ RetainedSessionHolder (@ActivityRetainedScoped / nav-scoped)
├── components/ KeyBar, QuickReply, QuickReplyStore, GateBanner, PlanGateSheet, ReconnectBanner,
│ AwayDigestView, TelemetryChips, TerminalContextMenu, ContinueLastBanner
├── push/ FcmService, DenyBroadcastReceiver, AllowTrampolineActivity, PushRegistrar, NotificationBuilder
└── nav/ DeepLinkRouter, NavGraph
4. The server contract (single reference for the Android side)
The complete surface the client uses, confirmed against src/server.ts. The server is unchanged for everything except one additive P1 touch-point (§4.5).
4.1 WebSocket — wss://<host>/term (path from WireConstants.wsPath)
Single connection, JSON text frames. attach MUST be the first frame on every (re)connect. Origin header stamped from HostEndpoint.originHeader.
Client → server
| type | shape | notes |
|---|---|---|
attach |
{ "type":"attach", "sessionId": <uuid-v4-lowercase> | null, "cwd"?: "/abs/path" } |
sessionId key always present (JSON null for new session — server rejects a missing key). cwd appended only when sessionId is null. |
input |
{ "type":"input", "data": <string> } |
raw keyboard bytes, verbatim, never filtered. |
resize |
{ "type":"resize", "cols": <int 1..1000>, "rows": <int 1..1000> } |
own message → ioctl(TIOCSWINSZ)→SIGWINCH. Validate range before send (server silently drops out-of-range). |
approve |
{ "type":"approve", "mode"?: "acceptEdits" | "default" } |
mode is a TOP-LEVEL key. No key = plain approve. |
reject |
{ "type":"reject" } |
Server → client (every frame untrusted; undecodable → drop + count, never crash)
| type | shape | notes |
|---|---|---|
attached |
{ "type":"attached", "sessionId": <uuid> } |
always adopt the server-issued id. Must pass v4 regex AND UUID parse. |
output |
{ "type":"output", "data": <string> } |
opaque ANSI/UTF-8 → emulator verbatim. Ring-buffer replay arrives as one large frame right after attach (prefixed by ESC[0m — do NOT strip). |
exit |
{ "type":"exit", "code": <int>, "reason"?: <string> } |
terminal; code == -1 = spawn-failure, non-retryable, reason required. |
status |
{ "type":"status", "status": <ClaudeStatus>, "detail"?, "pending"?: bool, "gate"?: "plan"|"tool" } |
cockpit side-channel. pending absent → false; unknown/absent gate while pending → treat as tool. |
telemetry |
{ "type":"telemetry", "at": <ms>, ...optional metrics } |
at is the only required field (missing → drop whole frame). |
Client-side frame max message size raised to 16 MiB (guard with a client-side cap) to receive ring-buffer replay; oversized → non-retryable replayTooLarge (§9 R6). The large replay append is chunked and off-main (§6.2).
4.2 Read-only HTTP (GET, no Origin header)
| Route | Returns |
|---|---|
GET /live-sessions |
[LiveSessionInfo] — { id, cols, rows, status, cwd?, title?, telemetry?, lastOutputAt? } (list-lossy; unknown status → .unknown; lastOutputAt already serialized server-side → unread dots unblocked) |
GET /live-sessions/:id/preview |
SessionPreview { id, cols, rows, data } — data ~24 KiB opaque ANSI (≤256 KiB cap client-side) |
GET /live-sessions/:id/events |
[TimelineEvent] — away-digest + activity-timeline source (consumed by A28) |
GET /config/ui |
UiConfig { allowAutoMode } |
GET /projects |
[ProjectInfo] (list-lossy) |
GET /projects/detail?path= |
ProjectDetail { sessions, worktrees, claudeMd, … } |
GET /projects/diff?path=&staged=1|0 |
DiffResult (files→hunks→lines; staged must be '1'/'0') |
GET /prefs |
UiPrefs (opaque JSON object — preserve unknown top-level keys) |
4.3 Guarded HTTP (state-changing — MUST send Origin == HostEndpoint.originHeader byte-equal; foreign/missing → 403)
| Route | Body | Returns |
|---|---|---|
DELETE /live-sessions/:id |
— | 204 (404 = already gone = success) |
POST /hook/decision |
{ sessionId, decision: "allow"|"deny", token } |
204 (400 malformed, 403 stale/used/mismatched token, 429 ≤10/min). Token single-use, from push payload only — never persist/log. |
PUT /prefs |
full blob ≤64 KB | 200 echo (replaces whole blob → rewrite the unknown-key-preserving object) |
DELETE /live-sessions(kill-all) is NOT consumed (iOS lacks it). Do not build it.
4.4 Endpoints the iOS client does NOT use (match iOS — do not consume unless expanding scope)
GET /sessions, POST /hook, POST /hook/permission, POST /hook/status, POST /open-in-editor, POST /projects/worktree, DELETE /live-sessions (kill-all). Push web-only: GET /push/vapid-key, POST/DELETE /push/subscribe (Android uses FCM, not VAPID).
Amendment (2026-07-30). This list is out of date on two entries — verified on disk, not assumed:
POST /projects/worktreeis consumed by both clients now (android/app/.../viewmodels/WorktreeViewModel.kt,ios/App/WebTerm/Screens/WorktreeSheet.swift). Remove it from this list.GET /sessions(claude --resumehistory) is consumed by iOS only (ios/Packages/APIClient/Sources/APIClient/History.swift:71); Android has zero references. So the section's premise — "match iOS" — now argues for building it on Android, rather than against. That is an open parity gap, not a prohibition.Both changes landed in the
ios-completionwave — seeplans/ios-completion.md.Planned scope expansion, not yet built: the five
/orphan-sessions*routes (feature A, shipped server-side 2026-07-30) are a deliberate future addition for both clients — plan inplans/w7-orphan-session-clients.md. They are not listed in §4.2/§4.3 yet, on purpose: this doc must never claim a capability that does not exist. Move them there when the work lands, not before.
4.5 The ONE additive server change Android requires (P1 push)
APNs routes (POST/DELETE /push/apns-token) do not transfer to FCM. Add, mirroring src/push/apns.ts behind the existing combineNotifyServices(...) seam (zero new event paths — notify(session, cls, token?) already carries needs-input/done):
- NEW
src/push/fcm.ts— aNotifyServiceimpl: OAuth2 bearer viagoogle-auth-library(GoogleAuth/JWTwith the service-account key + scopehttps://www.googleapis.com/auth/firebase.messaging; the lib handles token mint + ~1h refresh) →POST fcm.googleapis.com/v1/projects/{PID}/messages:sendwith{message:{token, data:{sessionId,cls,token?}, android:{priority:"high"|"normal", ttl}}}. Data-only (nonotificationblock). Prune onUNREGISTERED/INVALID_ARGUMENT; the lib auto-refreshes on 401. DECIDED (R7): usegoogle-auth-library(add as a server dep) rather than hand-rolling RS256 — less crypto to own/test thanapns.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,base64urlcharset plus:— FCM token format/length is undocumented and changes; a strict{140-170}regex risks rejecting valid tokens). SameOrigin-guard + rate limit. - NEW env group
FCM_*(project id + service-account key path), all-or-disabled likeloadApnsConfig.
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 (sealedClientMessage/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;HostEndpointorigin table vssrc/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;TitleSanitizerstrips bidi/zero-width + 256-char cap. - A7
Owns: :transport-okhttp/**.Verify:Originheader reaches server (integration); clean-finish vs error preserved viachannelFlow; 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-consumerChannelfan-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 stillESC O Aunder 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-1spawn-failure copy;replayTooLargeno-spinner actionable copy + "new session"; toolbar "new session in cwd" →attach(null, cwd). - A27
Owns: :app screens/ClientCertScreen.kt, viewmodels/ClientCertViewModel.kt.Verify:SAF.p12import → 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→clearLastSessionStore; banner in stack + sidebar. - A30
Owns: :app push/{FcmService,NotificationBuilder,DenyBroadcastReceiver,AllowTrampolineActivity}.kt.Verify:Deny viaBroadcastReceiver(goAsync()+ expedited POST, no UI); Allow via translucentexcludeFromRecentsActivity hostingBiometricPromptthen 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 TermuxTerminalEmulatorwith the JNI process removed renders one canned screen from WS-shaped bytes; sizes theTerminalSessionreplacement 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:
- Typing (IME):
TerminalView→RemoteTerminalSession.writeInput(stock TermuxInputConnectionpath, redirected from process-write to WS-send). - Key-bar taps: Compose buttons →
KeyByteMap.bytes(key)→engine.send(Input(...))bypassing the IME entirely (soft keyboard never pops), mirroring the webws.sendbypass. - Hardware chords:
onKeyEvent→ Esc/Ctrl-letter/⇧Tab map viaKeyByteMap; arrows/Enter/Tab are left to Termux'sKeyHandlersocursorKeysApplication(DECCKM) still emitsESC O Afor vim/htop. HardcodingESC [ Awould 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
TerminalBuffertranscriptRows(~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+ AndroidActionMode→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,
AndroidViewfactory re-run) from real backgrounding usingisChangingConfigurations()/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 overandroid:configChanges, which breaks resource re-resolution and posture changes.) - On real background (
ON_STOPthat is not a config change):engine.close()(clean detach; server PTY survives). On real foreground return after a background stop: rebuild the stack, bumpgeneration, key theAndroidViewbygenerationso 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 onMain.immediatebut 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.
- 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. SameRemoteTerminalSessionseam applies. - 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; feedoutputviaevaluateJavascript("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 innetwork_security_config'sbase-config. CORRECTED (was wrong in this plan until the A19 fix): the original text said cleartext would be "permitted only for private LAN/Tailscale CIDRs vianetwork_security_config". That is not expressible. The network-security-config format has NO netmask/prefix/CIDR attribute anywhere — a<domain>holds one hostname or one IP literal, andincludeSubdomainswalks DNS labels, so it cannot generalise192.168.0.0/16. Enumerating ~18.9M addresses is not an option either. Since a LAN address is user-typed at runtime and this file is a build-time resource, it also cannot be scoped to "the IP the user actually entered". So: cleartext is ON inbase-config,*.terminal.yaojia.wangkeeps an explicitcleartextTrafficPermitted="false"block (the one hostname known at build time), trust anchors are pinned tosystemonly, and the real guard moves into the app — the §5.4 warning tiers plus confirm-before-network. The scoping the platform cannot express is additionally enforced at transport time inOkHttpClientFactory, which refuses a plaintext dial to a non-private host. Honest tradeoff: on barews://the terminal bytes and theWEBTERM_TOKENcookie are readable and replayable by anyone on the path. This is a LAN/Tailscale posture, never an internet-exposure one. - Self-signed LAN
wss: DEFERRED — a documentedCertificatePinner/customX509TrustManagerpath, 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—MessageCodecgolden-vector byte-equality vs captured web-client frames (explicitsessionId:null, top-levelapprove.mode, JS control-char escaping, lowercase UUIDs); tolerant decode (bad JSON/unknown type/missing-required → null; wrong-typed optional → absent;telemetry.atmandatory);Validation(v4 regex; resize 1..1000; absolute cwd);HostEndpointorigin/wsURL vssrc/http/origin.ts(default-port omission, lowercase, IPv6 brackets).:session-core—ReconnectMachineladder + no-reset-on-foreground;PingScheduler25s/2-miss (drives onlyPingableTermTransport);GateTrackerrising-edge epoch + two-line stale-guard +canDecide;AwayDigestonce-per-reconnect;UnreadLedgerwatermark/cap/tie-break;TitleSanitizerbidi/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/HostClassifiertiers (fail-safe unknown→public).:client-tlspure half — PKCS12 parse happy + wrong-passphrase (UnrecoverableKeyException) + corrupt (EOFException);X509KeyManageralias/getPrivateKeyselection;CertificateSummaryfields; 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-tlsframework half (real AndroidKeyStore — NOT Robolectric) — import happy + no-identity mapping; re-readingX509KeyManagerpresents 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 →NotificationBuilderAllow/Deny actions; DenyBroadcastReceiverPOSTs/hook/decision(token never logged); Allow trampoline Activity hosts a mockBiometricPromptthen 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-derivedOrigin(frozen in:wire-protocol), byte-equal tonew URL(); stamped on the WS handshake and the 3 guarded HTTP routes only. The single non-skippable CSWSH defense (TECH_DOC §7). Verified vssrc/http/origin.ts. - mTLS trust model (one key home) — private key imported non-exportable into
AndroidKeyStore; a custom re-readingX509KeyManagerreturns the AndroidKeyStorePrivateKey/chain per handshake;KeyStore("PKCS12")is import-parse-only (transient), never the runtime home; NOT systemKeyChain. Client cert presented on both WS and REST via one sharedOkHttpClient. 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
.p12blob 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 — cleartext permitted in
base-config(NO CIDR syntax exists — see §6.9),*.terminal.yaojia.wangexplicitly denied, trust anchorssystem-only, nodebug-overrides, and a transport-time private-host check inOkHttpClientFactory; locked byNetworkSecurityConfigTest;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 —
runProberefuses to probe*.terminal.yaojia.wangunless 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/decisioncapability token validated as v4 UUID, passed only to the POST, never persisted, never logged, never in fallback copy; payload minimized (sessionId/cls/tokenonly). - Notification-action trust split — Deny →
BroadcastReceiver(goAsync(), no UI, expedited POST). Allow → a translucent,excludeFromRecentstrampolineActivitythat hostsBiometricPromptthen POSTs — aBroadcastReceiver/Servicecannot presentBiometricPromptandgoAsync()'s ~10s budget would blow. (Corrects the draft's "no app open" claim for Allow.) AllPendingIntentsFLAG_IMMUTABLE(API 31+). Deny is auth-free (fail-safe). The decision POST is expedited (not deferrableWorkManager); the single-use token makes a retried-after-success POST return 403 → idempotent retry-safety. - App Links —
assetlinks.jsonover 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/AnnotatedStringautolink; PR badge is a tappable link only when the URL parseshttps. OSC titles routed throughTitleSanitizer. - 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_NOTIFICATIONSrequested with rationale (API 33+);FLAG_SECURE+ privacy shade so the recents snapshot never leaks terminal bytes. NoNEARBY_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. 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 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≈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)
- AW0 A1→A2→A3 (serial). Simultaneously kick off A33 (server FCM — independent) and stand up S1 and S2 sandboxes.
- 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.
- 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. - AW2 A14 (engine) then A15 (wiring/DI freeze) — everything terminal/UI waits on the
SessionEventcontract and the frozen wiring seam. - 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.)
- 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.
- AW5 — pull A30/A31 forward alongside AW3; A32 deep links after A21; needs A33 on a test Firebase project for end-to-end.
- 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
excludeFromRecentstrampolineActivityhostingBiometricPrompt; Deny stays aBroadcastReceiver. §5 A30, §8, §1 P1 updated. - mustFix (engine ownership / config-change survival): committed to a
RetainedSessionHolder(viewModelScope/@ActivityRetainedScoped), distinguishing config change from real background viaisChangingConfigurations()/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-tlssplit into pure + framework halves (A10/A11, pure half in the Kover gate);:terminal-viewboundary pinned to:wire-protocolonly (decode in A21); per-consumerChannelEventBus (R10, A15); expedited decision POST notWorkManager(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/
replayTooLargecopy + "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 +LastSessionStorelifecycle) → A29. - Adopted:
TitleSanitizernamed in A6;DELETE /live-sessionskill-all marked out-of-parity (§1 DEFERRED, §4.3/§4.4); host-menu contents enumerated (A20); iPad Projects grid assigned (A23);lastOutputAtnoted 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 todataSync/mediaProcessing6h-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/localNetworkDeniedremoved (R12);ContextMenuArea→pointerInputDropdownMenu(R13, A26);EncryptedFileinconsistency 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-emulatorare 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 (nevertermux-shared/app module, which are GPLv3).
Decisions locked (no open questions remain)
R2 (BLOCKS A16 / AW3): Termux is GPL and must be vendored.RESOLVED (verified 2026).terminal-view/terminal-emulatorare Apache-2.0 on JitPack; no copyleft as long as the dependency is scoped to those two libs (nottermux-shared/app). AW3/S1 not gated on a license call. Residual technical-only choice: JitPack-consume vs vendor-pin → default JitPack0.118.0.- 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.
- 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.