# Android Client — Progress (this-session working log) > **Why this file exists (temporary):** `docs/PROGRESS_LOG.md` (the canonical cross-session > memory) was being **concurrently edited by another live session** (the tunnel-automation > workstream on branch `feat/tunnel-automation`) while this Android work ran on the SAME working > tree. To avoid a read-modify-write clobber of that session's log, the Android orchestrator > records progress here instead. **FOLD THESE ENTRIES INTO `docs/PROGRESS_LOG.md` once the > concurrent session is done** (they belong under the `🤖 ANDROID CLIENT` section). > > **Git status note:** nothing below is committed. The Android lane (`android/**`, > `src/push/fcm.ts`, `test/push-fcm.test.ts`, `src/server.ts` FCM wiring, `package.json` > google-auth-library) is file-disjoint from the tunnel work, but the shared `.git`/index means > **no `git add -A`** — commit the Android lane by explicit path once branch strategy is settled. Plan: [`../docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md). 36 tasks, waves AW0–AW6. Prior state (commit `4ea8f78`/`4fe1981`): AW0 + AW1 pure-Kotlin foundation (218 tests) + Android SDK/AGP 9.2.1 toolchain proven. --- ## [x] Wave R1 — A7 + A14 + A33 (DONE, orchestrator-verified 2026-07-08) One implementation Workflow (3 TDD builders in parallel) → 6-agent adversarial cross-review (2 independent lenses/task) → fix Workflow (must-fixes + regression tests) → adversarial re-verify → **orchestrator independent unified gate** (not just agent self-report). **Unified verification (measured):** - `cd android && ./gradlew test` → **BUILD SUCCESSFUL, 250 tests, 0 failures** (wire-protocol 47 / session-core 75 / api-client 69 / client-tls 27 / test-support 15 / **transport-okhttp 17**). - `npx vitest run test/` → **54 files, 1533 tests, 0 failures** (incl. new `test/push-fcm.test.ts` 60 tests; no regression of the existing server suite). - `npm run typecheck` (tsc main + web) → clean. ### [x] A7 `:transport-okhttp` — OkHttp WS + REST transport (pure JVM; MockWebServer-tested) - Files: `transport-okhttp/src/main/.../transport/{OkHttpClientFactory, OkHttpTermTransport, OkHttpWebSocketConnection, OkHttpHttpTransport}.kt` + 3 test files. Implements frozen `TermTransport`/`PingableTermTransport`/`HttpTransport` verbatim (contracts unmodified). - WS: `connect()` stamps `Origin = endpoint.originHeader` on the upgrade (CSWSH; asserted byte-equal); `frames` = `channelFlow` draining an unlimited listener mailbox — clean close → normal completion, `onFailure` → error (distinguishable); `send`→`webSocket.send`, `close`→`close(1000)` (**detach, never kill**). REST: non-2xx RETURNED, transport-failure THROWN, headers verbatim (no self-added Origin). Shared `OkHttpClient` `.cache(null)` (§8) + default system trust; mTLS via a LOCAL `ClientIdentityProvider` fun-interface seam (module depends only on `:wire-protocol`, no `:client-tls` edge). - **Cross-review fixes (regression-tested):** (1) HIGH — handshake socket leak on mid-dial cancel: `openConnection` now tears down the just-created WS on any throwable (incl. `CancellationException`) via an idempotent `cancel()` (`webSocket.cancel()`+`finish(null)`) before rethrowing — proven red→green by revert. (2) HIGH — the redundant transport-level 16 MiB frame cap (`TransportFrameTooLargeException`) was **deleted**: it was unreachable from `:session-core` (which may depend only on `:wire-protocol`), and `SessionEngine` already self-measures frames and emits `REPLAY_TOO_LARGE` — the single authoritative classifier. (3) MEDIUM — pump rethrows `CancellationException` (defensive; kept as a contract lock). ### [x] A14 `SessionEngine` — pure lifecycle state machine (`:session-core`, runTest virtual time) - Files: `session-core/src/main/.../session/SessionEngine.kt` + `SessionEngineTest.kt` (15 tests). Composes ReconnectMachine/PingScheduler/GateTracker/AwayDigest over an injected `TermTransport` + dispatcher/TimeSource; NO OkHttp/Android imports. - Verified: attach-first ordering, adopt-server-id, connect-now-on-foreground, **close≠kill**, oversized-replay→terminal `REPLAY_TOO_LARGE`, gate-decision epoch drop, backoff ladder 1→2→4→8→16→30, ping 25s/2-miss — all under virtual time via the fakes. - **Cross-review fixes (regression-tested):** (1) HIGH (found independently by BOTH reviewers) — `close()` during the dial/attach window failed to detach the freshly-opened connection: engine now re-checks the `closed` flag after dial and after attach, closing + returning terminal with no further emits (2 new tests: close-during-dial, close-during-attach). (4) MEDIUM — gate double-send: `decideGate()` now locally retires the held gate after a successful send so a second same-epoch tap is dropped (new test). (2) MEDIUM — `started` flip moved onto the confined dispatcher (invariant #4). (3) MEDIUM — suspend-call `runCatching` replaced with cancel-rethrowing `ignoringNonCancel`. - **KNOWN follow-up routed to A15 (AW2):** the engine does not close its live WS when its *scope* is cancelled (only on explicit `close()`). Intended teardown is explicit `engine.close()` (per §6.6), so **the `RetainedSessionHolder` MUST call `engine.close()` before cancelling the engine scope**; additionally consider a `finally`-close in the engine for defense-in-depth. Non-blocking for R1 (server PTY survives either way; only affects prompt-vs-TCP-timeout detach). ### [x] A33 server FCM — the ONE additive server change (§4.5) - Files: NEW `src/push/fcm.ts` (`NotifyService` impl behind a seam) + `POST/DELETE /push/fcm-token` route + `initFcm`/`normalizeFcmToken` wiring in `src/server.ts` (combined via `combineNotifyServices` beside web-push/APNs — zero new event paths) + `FCM_*` env (all-or-disabled) + `test/push-fcm.test.ts` (60 tests). Added `google-auth-library@^10` to `package.json` deps (R7 decision). - Data-only high-priority messages; payload minimized to `sessionId`/`cls`/`token` (no notification block, no cwd/command); loose FCM-token validator; token/key never logged; disabled cleanly when `FCM_*` incomplete. - **Cross-review fix (regression-tested):** HIGH — removed `validateStatus:()=>true`, which had silently defeated google-auth-library's built-in 401/403 refresh-and-retry (the whole reason R7 chose the lib). Now wraps `client.request` in try/catch, reading `{status,body}` from `GaxiosError.response`; a 401 goes through the lib's refresh before surfacing; a no-response transport error is a send failure (token never falsely pruned). 3 new tests (401→refresh→200, 404/UNREGISTERED→prune, no-response→keep). **Not run here (deferred per plan §7):** instrumented/device tests (no emulator installed) and the S2 FCM real-handset delivery spike (needs ≥2 physical devices under Doze/OEM battery managers). --- ## [~] Wave R2 — A13 (done) · A11 + A12 (building) · S1 → moved to head of AW3 ### [x] A13 `:app` Compose baseline (DONE, orchestrator-verified 2026-07-08) First real full Android app module — establishes the UI-stack version matrix every later Android task reuses. Workflow: TDD builder → kotlin + design cross-review. - Files: `app/build.gradle.kts`, `AndroidManifest.xml`, `WebTermApp.kt` (@HiltAndroidApp), `MainActivity.kt` (@AndroidEntryPoint), `di/AppModule.kt` (minimal Hilt), `designsystem/{DesignSpec, Tokens,Typography,Theme,Primitives}.kt`, `res/{values,xml}/*`, `DesignSpecTest.kt` (5 JVM tests). - **Version matrix PROVEN** (`:app:assembleDebug` → 32.7 MB APK, `:app:testDebugUnitTest` green): AGP 9.2.1 · Kotlin 2.3.21 · compose-compiler plugin 2.3.21 · Compose BOM 2025.11.01 (material3 1.4.0, ui 1.9.5, material3.adaptive 1.2.0) · Hilt 2.60.1 via KSP 2.3.9 · **compileSdk 36** (installed `platforms;android-36`; SDK-37 unavailable — cmdline-tools too old), minSdk 29, targetSdk 35. Design system mirrors iOS DS 1:1 (amber-gold #E3A64A/#C9892F, semantic status colors, 8pt scale, tabular mono numerals, dark-first, fixed terminal-canvas #100F0D/#ECE9E3/gold). - **Cross-review fix (orchestrator applied + re-verified assemble green):** both reviewers flagged the primitives (StatusBadge/TelemetryChip/WebTermCard) hardcoding `WebTermColors.dark.*` → would render wrong in light theme. Routed through `MaterialTheme.colorScheme.{onSurfaceVariant, surfaceVariant,outline}` (Theme.kt already maps those). Also fixed preview `.dp` literals → `Spacing.*`. Docs synced: `android/README.md` + `settings.gradle.kts` recipe + `[[android-build-env]]` memory now say compileSdk 36 / android-36. - Deferred (per §7): instrumented/Compose-UI tests (no emulator). Follow-up for AW3: add a `Motion.gated()` helper (reduce-motion) to Tokens.kt when the first animation lands (LocalReduceMotion + DesignSpec.MOTION_* already present); register a ContentObserver on ANIMATOR_DURATION_SCALE then. ### [x] A11 `:client-tls-android` — ClientTLS framework half (DONE, orchestrator-verified) Catalog pre-staged (tink 1.15.0, datastore-preferences 1.1.1, androidx-test); enabled in settings. Build workflow → security+kotlin cross-review → fix workflow → adversarial security re-verify → orchestrator applied 3 more fixes the re-verify caught + independent compile gate. - Files: `client-tls-android/src/main/.../tlsandroid/{AndroidKeyStoreImporter, TinkCertStore(+CertStore iface), ReReadingX509KeyManager, IdentityRepository(+ClientSslMaterial), StoredIdentityMetadata}.kt` + androidTest `{Fixtures, AndroidKeyStoreImporterTest, IdentityRepositoryTest}.kt`. - ONE key home = AndroidKeyStore (non-exportable, per-handshake re-reading `X509KeyManager`); Tink AEAD encrypts ONLY the cert-chain+metadata blob; NO `.p12`/passphrase persisted; validate-before-persist; `connectionPool.evictAll()` on rotate/remove. Exposes `IdentityRepository`/`ClientSslMaterial` as the seam A15 bridges to `:transport-okhttp`'s `ClientIdentityProvider` (NO `:transport-okhttp` dep). - **Security must-fix (rotation safety) — resolved via single-commit + adversarial follow-through:** the re-verify caught the fixer's first attempt still lost the prior identity. Final design: **ping-pong staging slot → one atomic durable `SharedPreferences.commit()` live-pointer flip** (`StoredIdentityMetadata.keyStoreAlias`). Any failure before the flip → prior identity fully live; after → new identity fully live; stores never diverge. Orchestrator then fixed 3 residual bugs the security re-verify empirically proved: **(1 HIGH)** `currentLive()` used `liveOverride?.value ?: initialIdentity` which collapsed `Box(null)` (explicitly removed) into the stale cached identity → a *removed* cert stayed "live" and could be presented with a dangling key; now resolves via Box presence. **(3)** `TinkCertStore.save()/clear()` switched `apply()`→`commit()` (durable + throws on failure) so a crash-window can't leave the pointer naming a just-deleted key (both-identities-lost). **(2)** widened staging-key cleanup to cover the key-readback/encode steps. Added instrumented regression `remove_afterStartupTouch_reportsNoIdentity_notStaleCached`. - Verified (orchestrator): `./gradlew :client-tls-android:assembleDebug :client-tls-android:compileDebugAndroidTestKotlin` → BUILD SUCCESSFUL (main + instrumented compile). All AndroidKeyStore/Tink tests run on device (§7). ### [x] A12 `:host-registry` — Host/HostStore + DataStore + LastSessionStore (DONE, both reviewers approved) - Files: `host-registry/src/main/.../hostregistry/{Host, HostStore(+immutable transforms), InMemoryHostStore, DataStoreHostStore, LastSessionStore, DataStoreLastSessionStore, HostCodec}.kt` + tests (JVM unit: HostStoreTransforms/InMemoryHostStore/HostCodec/InMemoryLastSessionStore) + androidTest. - Immutable HostStore (upsert/remove return new lists, position-preserved, unknown-id no-op); DataStore read-modify-write; HostEndpoint re-validated on load; LastSessionStore set-on-adopted / clear-on-exited. **Orchestrator fix:** `DataStoreLastSessionStore` now guards the persisted id with the frozen v4 `Validation.isValidSessionId` (was `UUID.fromString` format-only). - Verified (orchestrator): `./gradlew :host-registry:assembleDebug :host-registry:testDebugUnitTest` → BUILD SUCCESSFUL, **17 JVM unit tests green**; DataStore-backed tests are androidTest (device QA). ### S1 renderer spike — relocated to the head of AW3 (it gates A16, the XL renderer task). **Wave R2 complete.** Android modules now: 6 pure-JVM (250 tests) + `:app` + `:host-registry` + `:client-tls-android` (framework, assemble+unit-verified here; instrumented deferred to device QA). --- ## [x] Wave AW2 — A15 `:app` wiring + DI FREEZE (DONE, orchestrator-verified 2026-07-09) The convergence/contract task that gates all of AW4. Build → arch+coroutine cross-review (BOTH blocked) → fix (6 must-fixes) → arch+coroutine re-verify (caught 1 more real bug) → residual fix → orchestrator added the final test lock + independent gate. `./gradlew :app:assembleDebug :app:testDebugUnitTest :session-core:test` all green (EventBus 6 tests, RetainedSessionHolder 4, DesignSpec 5, SessionEngine 15). - Files: `app/src/main/.../wiring/{EventBus, TerminalSessionController, RetainedSessionHolder, AppEnvironment, ColdStartPolicy, ApiClientFactory, SessionEngineFactory}.kt` + `di/{NetworkModule, TlsModule, StorageModule, SessionModule}.kt` (replaced A13's placeholder AppModule) + tests. - **Frozen contracts (AW4 depends on these):** per-session `EventBus` (NOT @Singleton) with two typed sub-streams `outputBytes(): Flow` (Output-only, UTF-8 decode off-Main via flowOn) + `controlEvents(): Flow` (non-Output); `TerminalSessionController` (start() decoupled from bind(), eager mailbox registration); `RetainedSessionHolder` (@HiltViewModel, config-survival, single-session-for-life BoundKey guard, close-join-before-cancel teardown); `AppEnvironment.warmUp()` (off-Main first identity touch); `ColdStartPolicy`; factories for per-host ApiClient + per-session SessionEngine. mTLS bridge: A11 `IdentityRepository` → A7 `ClientIdentityProvider` → ONE shared `OkHttpClient` (.cache(null), WS+REST) — construction cycle broken via a shared `ConnectionPool`. - **Coordination change (authorized):** `SessionEngine.close()` now returns its `Job` so teardown can `join()` the clean detach BEFORE cancelling the confinement scope (structural close-before-cancel). - **Cross-review caught + fixed:** (HIGH) EventBus was @Singleton → cross-session event bleed → now per-session; (HIGH) bind()-started-engine-before-UI-subscribe → lost `attached`+replay → decoupled start() + eager registration; (HIGH) eager DI did AndroidKeyStore/Tink I/O on Main → dagger.Lazy + warmUp() on IO; (HIGH, re-verify) `register()`'s finally-close made the reused controller's output/ events flows DEAD after one config-change (blank terminal on rotation) → mailbox now lives for the session (receiveAsFlow consume=false), released only by `EventBus.close()` at teardown; + typed sub-streams, structural close, bind mis-route guard, @Volatile started. R10 (per-consumer UNLIMITED channels: slow consumer neither stalls nor drops) unit-tested; confinement invariant #4 intact. ## [~] Wave AW3 — A16 `:terminal-view` DONE · A17 KeyBar + A18 ThumbnailPipeline building ### [x] S1 + A16 `:terminal-view` (XL) — the renderer, DONE + orchestrator-verified 2026-07-09 **S1 gate PASSED headless** (no device): a Termux `TerminalEmulator` (no JNI/subprocess) fed canned WS bytes renders into a readable `TerminalBuffer` ("hello", cursorCol 5); DSR reply routes out via `TerminalOutput.write`→engineSend; DECCKM `ESC[?1h` flips to `ESC OA`. **Termux resolved via JitPack** `com.github.termux.termux-app:{terminal-view,terminal-emulator}:v0.118.0` (only transitive dep androidx.annotation; no guava → R2 shim unneeded; Apache-2.0 scope held — never termux-shared/app). JitPack repo + coordinate added to settings/catalog; `:terminal-view` enabled. - Files: `terminal-view/src/main/.../terminalview/{RemoteTerminalSession, RemoteTerminalView, TerminalGridMath, NoOpTerminalSessionClient, LinkPolicy}.kt` + 5 test suites (17 unit tests). - `RemoteTerminalSession` = the FORK (no subprocess): a single confined-writer via an ordered `Channel` (Feed/Resize/Bind/Unbind); append chunked 4 KiB + yield(); `onScreenUpdated` posted to an injected main dispatcher. pendingOutput queued-then-flushed in order (ESC[0m preserved). Resize math extracted as pure `TerminalGridMath` (R5), JVM-tested. Titles pass RAW (no :session-core edge — :app wires TitleSanitizer); OSC-52 declined; http/https LinkPolicy. - **Sound deviation (§6.1):** at v0.118.0 Termux `TerminalView`/`TerminalSession` are `final` → `RemoteTerminalView` is a COMPOSITION wrapper binding the forked emulator via the public `mEmulator` field (rendering stays 100% stock). Documented. - **Cross-review (correctness approved; threading blocked→fixed):** HIGH — bind published `mEmulator` to the renderer SYNC before the pendingOutput flush → bind-time UI-read-vs-confined-append race; fixed by routing the publish through the Bind command (flush on confined thread, THEN post publish+onScreenUpdated to Main together; test captures buffer state AT publish). MEDIUM — confined command loop now try/catches (rethrows Cancellation) so a hostile escape byte can't freeze output. LOW — added `kotlinx-coroutines-android` (else `Dispatchers.Main` crashes on-device). Verified: `:terminal-view:assembleDebug` + `testDebugUnitTest` 17/17 green. - **Accepted (not a defect):** steady-state append runs concurrently with the UI-thread `onDraw` — this is the intended §6.2 single-WRITER model (matches upstream Termux; torn read self-corrects next frame). - Deferred to device QA (§7): glyph/true-color rendering, IME/CJK, selection→clipboard, link-tap, rotation-rebind, real font-metric→grid (TerminalRenderer metrics are package-private → measured on-device, fed to the tested TerminalGridMath). ### [x] A17 KeyBar (DONE, green) — `app/.../components/KeyBar.kt` + test (12 tests) Compose mobile key-bar overlay porting `public/keybar.ts` (17 buttons, order/glyphs 1:1). Each tap → `KeyByteMap.bytes(key)` sent VERBATIM via an injected `onSend` (A21 wires `controller::sendInput`), bypassing IME/BasicTextField (soft keyboard never pops). Pinned above IME via `windowInsetsPadding(WindowInsets.ime.union(navigationBars))`, horizontally scrollable, hidden when `screenWidthDp>768` OR a hardware keyboard is present. **DECCKM split** = pure `HardwareKeyRouter.resolve()`: ⇧Tab→ESC[Z, Esc→ESC, Ctrl+{C,R,O,L,T,B,D}→control byte; everything else (arrows/Enter/Tab/unmapped) → `DeferToTerminal` so Termux `KeyHandler` emits DECCKM-correct `ESC OA` (never hardcoded `ESC[A`). Verified `:app` 27 tests green (KeyBar 12). Cross-reviewed: reviewers stalled (infra), but the byte contract is unit-locked. Device-QA: layout/IME-inset/real key delivery. A21 install: `remote.onKeyCommand = { kc,ev -> HardwareKeyRouter.handle(kc,ev,controller::sendInput) }` + `KeyBar(onSend = controller::sendInput)`. ### [x] A18 ThumbnailPipeline (DONE, green — review deferred) — `app/.../wiring/ThumbnailPipeline.kt` + test (5 tests) Off-screen preview rasterisation (§6.7): `LruCache` keyed `(sessionId, lastOutputAt)` (unchanged ⇒ cache hit, no re-render); fair `Semaphore(2)` FIFO fetch+render cap; in-flight dedup via `Map>` under a `Mutex` (2nd same-key request awaits the 1st); failure caches a PLACEHOLDER (no retry storm); preview fetched over the shared **mTLS** `ApiClient` (`GET /live-sessions/:id/preview`, 256 KiB cap); off-screen `TerminalEmulator` (no view) → `Canvas` cell painter behind a `Rasterizer` seam (bitmap draw device-QA'd; concurrency/cache logic JVM-tested). **NOTE:** A18's build agent + all 4 AW3 reviewers stalled ~3.5h (infra degradation); the agent had written the file first. Orchestrator fixed a 1-char test-name error (illegal `;` in a backtick name), re-verified `:app:assembleDebug + testDebugUnitTest` → **32 tests green**. A18's formal cross-review was NOT run — flag it for the AW6 acceptance pass (concurrency code warrants a second look). **Wave AW3 complete.** `:terminal-view` (17) + `:app` (32) + 6 pure-JVM (250) all green. The terminal render path — the plan's dominant risk — is proven and hardened. --- ## [~] Wave AW4 — 11 UI screens (A19–A29) ### [x] A21 Terminal screen + reconnect/EXIT banner + new-session-in-cwd (built, green — integration pass in flight) Files: `components/ReconnectBanner.kt` (pure `bannerModel` reducer: exited/failed OUTRANK connecting/reconnecting; exit -1 spawn-failure; REPLAY_TOO_LARGE no-spinner+new-session), `wiring/ TerminalSessionControllerImpl.kt` (wraps the holder controller `by base`, wires output→feedRemote before start, OSC title→TitleSanitizer), `screens/TerminalScreen.kt` (generation-keyed AndroidView, KeyBar/HardwareKeyRouter, FLAG_SECURE privacy shade, new-session-in-cwd → attach(null,cwd)). `:app` 64 tests (ReconnectBanner 8 + NewSessionInCwd 3). ### [x] A22 Gate/cockpit surfaces (built, green, reviewer approved) Files: `viewmodels/GateViewModel.kt` (two-line epoch stale-guard, decide via controller.decideGate, approve.mode top-level) + `components/{GateBanner (tool 2-btn), PlanGateSheet (plan 3-way sheet), TelemetryChips, AwayDigestView}.kt`. Built but not yet composed into the terminal screen (→ integration). ### [x] Terminal-path integration + A15 seam refinement (DONE, orchestrator-verified — `:app` 66 tests) Both re-verifiers confirmed (arch 7/0 broken cosmetic; kotlin 7/0). Frozen-contract changes: `TerminalSessionController.events` (single mailbox) → `controlEvents()` (per-consumer mailbox, no split); `RetainedSessionHolder.generation` → `mutableIntStateOf`; holder now owns `TerminalSessionControllerImpl` + `RemoteTerminalSession` (config-survival §6.6 — emulator+scrollback content survive rotation, no replay round-trip); GateViewModel + gate surfaces composed into TerminalScreen (haptic reduce-motion gated); warmUp error/retry pane. Confinement invariant #4 intact. Residual (cosmetic, tracked for AW6 doc pass): dangling `events` KDoc refs; scroll *offset* (not content) resets on rotation. Tests: TerminalSessionControllerTest (2, multi-consumer no-split), RetainedSessionHolder (4), GateViewModel (10). ### [x] AW4b — A19 Pairing + A20 Session list + A24 Diff + A25 Quick-reply (DONE, `:app` 111 tests green) Pre-staged CameraX (1.4.1) + ML Kit barcode (17.3.0) for A19 QR. All 4 reviewers approve/warn, 0 must-fix. - **A19 Pairing** — QR (CameraX+MLKit) / manual, both through the ONE `HostEndpoint.fromBaseUrl` validator; confirm-before-network; §5.4 tiers (public explicit-ack; tunnel cert-gated choke point retry can't bypass; fail-safe unknown→PUBLIC; tunnel-TLS→client-cert copy); no cert import; inert. - **A20 Session list** — STARTED-scoped poll, status/telemetry/thumbnail/cols×rows/sanitized-title/ unread-dot rows, swipe-kill (optimistic, 404=success), multi-host switch, host menu (配对新主机/设备证书). SessionListViewModelTest 11. - **A24 Diff** — staged flag as STRING "1"/"0", files→hunks→lines flatten, lossy decode, inert read-only. A24's builder died mid-run (API drop) leaving a missing `LaunchedEffect` import (blocked :app compile) + no test; orchestrator added the import + wrote DiffViewModelTest (8 tests: fromWire, flatten order, lossy decode, VM phase transitions + staged re-fetch). - **A25 Quick-reply** — DataStore CRUD palette (immutable), verbatim send, float-while-gate-held. ### [x] AW4c — A23 Projects + A27 CertScreen + A28 Timeline + A29 Cold-start (DONE, `:app` 164 tests green) Reviews: A27 approve; A23/A29 warn; all 0 must-fix except A28's "wired to away-digest expand" (a composition/nav wiring → tracked for the app-assembly pass below, not a code defect). - **A23 Projects** — ProjectGrouping BYTE-IDENTICAL group keys to web/iOS (" active"/" other" sentinels, first-seen-cased namespace keys, MIN_GROUP_SIZE=2); favourites/collapse via `/prefs` unknown-key- preserving (R11: never PUT if never loaded); detail page; open-Claude-here → attach(null,cwd,"claude\r"); grid column seam (A26 owns full adaptive). ProjectsViewModelTest 12. - **A27 ClientCertScreen** — import(SAF .p12)/rotate/remove over `:client-tls-android` IdentityRepository; validate-before-persist keeps prior cert on bad passphrase; summary (issuer/subject-CN/expiry/EXPIRED); passphrase scrubbed, never logged; confirm-gated remove; inert. ClientCertViewModelTest 14. - **A28 Timeline** — TimelineSheet(ModalBottomSheet) + TimelineViewModel over `/events`, lossy decode, tokenized event colors. TimelineViewModelTest 16. (away-digest→sheet wiring → app-assembly.) - **A29 Cold-start** — SessionActivityBridge (set LastSessionStore on adopted / clear on exited), ContinueLastBanner (stack+sidebar), ColdStartPolicy route (no host→pairing). Tests: bridge 6 + policy 2. ### [x] A26 Adaptive large-screen + nav shell (DONE, review warn 0 must-fix) Pure `LayoutPolicy.mode(WindowWidth)` (compact→STACK, medium/expanded→LIST_DETAIL) + `PointerMenuPolicy. enabled(mode, sw>=600)` — both JVM-tested (LayoutPolicyTest 8). `AdaptiveHome` = NavigationSuiteScaffold + ListDetailPaneScaffold keyed on `currentWindowAdaptiveInfo()`. `PointerContextMenu` = `pointerInput` secondary-click → `DropdownMenu` (NOT ContextMenuArea), gated. Full NavGraph wiring → A32/app-assembly. **Wave AW4 COMPLETE — all 11 screens (A19–A29) built + verified; `:app` green.** --- ## [~] Wave AW5 — push + deep-links (built; wiring → app-assembly) ### [x] A30 FCM client (green — `:app` 222 tests, PushDecisionTest 14) `push/{FcmService, NotificationBuilder, DenyBroadcastReceiver, AllowTrampolineActivity, PushPayload, PushDecisionSubmitter, PushTokenSink}.kt`. Data-only payload → notification built LOCALLY; **Deny** = BroadcastReceiver (goAsync + expedited POST, no UI, auth-free); **Allow** = translucent excludeFromRecents FragmentActivity hosting `BiometricPrompt` → POST (R1). Token single-use, only in FLAG_IMMUTABLE extras → ApiClient, never persisted/logged (test-asserted); payload minimized (no cwd/command/bytes). Multi-host: tries each paired host until 204 (foreign host 403s harmlessly). Biometric = BIOMETRIC_STRONG+negativeButton (minSdk-29 valid combo). Manifest (service/receiver/activity) + `Theme.WebTerm.Translucent` **applied by orchestrator; `:app` assembles**. ### [x] A31 PushRegistrar (green — PushRegistrarTest 7) `push/PushRegistrar.kt` — POST /push/fcm-token to each paired host, self-heal (token rotation / new host / removal→DELETE), token not logged. Review HIGH: not yet invoked in the app → **app-assembly** (DI: @Binds PushTokenSink + on-start/host-change invocation). ### [x] A32 DeepLinkRouter + NavGraph (green, review approve — DeepLinkRouterTest) `nav/{DeepLinkRouter, NavGraph}.kt` — pure `webterminal://open?host=&join=` + verified App-Links parser, v4-UUID whitelist on host+join (invalid ignored+counted, never partial), one router for cold/warm/push. Manifest intent-filters (custom scheme + `autoVerify` https) **applied by orchestrator; `:app` assembles**. ### [x] APP-ASSEMBLY — the app is a RUNNABLE whole (DONE, `:app` 227 tests, APK builds) `MainActivity` (@AndroidEntryPoint) → `WebTermNavHost` composing all screens; start destination from `ColdStartPolicy`; inter-screen callbacks wired (row/project/continue → terminal, host-menu → pairing/cert, away-digest → timeline); `AdaptiveHome` for large screens; PushRegistrar DI (@Binds PushTokenSink + on-start/host-change invocation); deep links via DeepLinkRouter (onCreate+onNewIntent); warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid+acyclic. Manifest (push components + deep-link intent-filters) applied by orchestrator. - **Review HIGH fixed by orchestrator:** cold-start deep links were dropped by a composition race (navigate before the NavHost graph was set) → moved `DeepLinkEffect` inside the `route != null` branch so it runs only after `WebTermNavHost` composed (graph set). Re-verified `:app` 227 green. - Minor gaps tracked (see DEVICE_QA_CHECKLIST): push-tap→specific-gate, diff inbound link, host-remove UI, the A21 Termux-view reflection shim. **Wave AW5 COMPLETE.** --- ## [x] Wave AW6 — acceptance (DONE, orchestrator-verified 2026-07-09) - **[x] A36 coverage gate:** Kover 0.9.1 applied to the 4 pure modules with an enforced `koverVerify` `minBound(80)` rule. Measured line coverage: **wire-protocol 91.0%** (added a HostClassifier/TimelineEvent test — the type lives here but was only tested cross-module, so its own report read 77% → 91%), **session-core 95.2%, api-client 89.2%, client-tls 96.4%**. `./gradlew koverVerify` GREEN. - **[x] Device-QA checklist:** `android/DEVICE_QA_CHECKLIST.md` — captures **A34** (instrumented E2E vs real `npm start`: attach→attached→output, reconnect replay, kill, hook/decision, **bad-Origin reject F9**) + **A35** (pair→attach→type→approve macrobenchmark) + the **S2** FCM real-handset spike + every per-task device-deferred item + the deploy artifacts (google-services.json, assetlinks.json, FCM_* env). A34/A35 are device/instrumented by definition (plan §7) — deferred to real hardware, not run here. - **[x] FINAL UNIFIED GATE (orchestrator, measured):** `./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify` → **BUILD SUCCESSFUL**. ~**484 JVM tests** (257 pure/transport + 227 `:app`) + Kover ≥80% + the full `:app` APK + `:terminal-view`/`:host-registry`/`:client-tls-android` all assemble. Server side (A33 FCM) unchanged since R1 (1533 server tests green). --- ## ⚠️ CORRECTION (2026-07-30): "COMPLETE" below meant COMPILES, not WORKS The section that follows claimed all 36 tasks had landed. That was true at the level it was measured — the modules built, the APK assembled and ~484 JVM tests were green — but NOTHING had ever run on an Android device or emulator, and the first thing that would have happened on one is a crash. An audit on 2026-07-30 found, and a repair pass fixed, the following. Read this before trusting anything below. **Three defects made the app unusable on real hardware.** Each was invisible to the JVM suite for the same structural reason: no JVM test instantiates an Android `View`. 1. **Every key press crashed.** `RemoteTerminalView` bound the forked emulator but never installed a `TerminalViewClient`. The pinned Termux `TerminalView` dereferences `mClient` with no null guard on the first line of the paths a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection` offset 0, `onKeyUp`, `onKeyPreIme`). The KDoc claimed "A17 sets it" — A17 never did; only the `HardwareKeyRouter` and the KeyBar shipped, and the KeyBar works precisely because it bypasses the view. Installing a client is not sufficient: returning false continues into `mTermSession.write()` at offset 150, and `mTermSession` is null forever (`TerminalSession` is final and forking a process is exactly what this app must not do, §6.1). Fixed by a client that consumes the key itself, with `KeyRouting` deliberately having NO defer-to-stock branch, so the crash is unrepresentable rather than merely avoided. `KeyHandler` remains the authority for the key tables, so DECCKM still emits `ESC O A`. 2. **No `resize` was ever sent.** `RemoteTerminalSession.updateSize` had zero call sites and the stock fallback is dead (`TerminalView.updateSize` early-returns without a session), so the PTY stayed at the server's 80x24 and every full-screen TUI rendered into the wrong box. `TerminalResizeDriver` now drives it from real cell metrics, read via the PUBLIC `TerminalRenderer.getFontWidth()` / `getFontLineSpacing()` accessors — no reflection (silently breaks under R8) and no re-measured `Paint` (drifts from what the renderer actually draws, the §R5 risk). 3. **Bare-LAN connections were impossible.** `network_security_config.xml` was a self-labelled STUB denying all cleartext, while `HostEndpoint` accepts `http` and derives `ws://`. Its own comment promised a CIDR allowlist the platform cannot express. See plan §6.9 for the corrected posture. **Adversarial review then found three more, and got one wrong:** - **Swipe-scrolling inside an alternate-screen TUI still crashed.** The touch path bypasses `TerminalViewClient` entirely: `doScroll` converts a scroll to `handleKeyCode` whenever the alternate buffer is active, which dereferences `mTermSession`. Claude Code IS an alternate-screen TUI, so this was a crash during ordinary use on the app's main screen. `TerminalScrollGesture` now claims the vertical drag first and reproduces all three `doScroll` branches, closing the fling runnable and `onGenericMotionEvent` with it. - **`autofill()` dereferences `mTermSession` unguarded** while the view advertises itself autofillable, so any password manager crashed the app. The subtree is excluded from the autofill structure. - The review also demanded stock text selection be RESTORED after the focus rework made it unreachable. It cannot be: the ActionMode's own Copy and Paste handlers dereference the absent session (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136), so a reachable selection UI has an NPE on its Copy button. **Long press stays consumed and copy-out is a recorded FEATURE GAP** — a first-party selection layer over `TerminalBuffer` + `ClipboardManager` is the real fix. This is the one place the reviewer was wrong and the builder right. **Silently-lost server data, now decoded:** the W2 `queue` frame had no `ServerMessageType` member so every one was dropped as undecodable (the "N queued" badge could never appear), and W1 `status.preview` was not modelled at all, which made remote approvals **BLIND** — the user was asked to approve a `Bash` call without seeing the command. Both now decode, with the rule that a broken preview never costs the frame (`pending` is what makes the gate appear). **Dead code that shipped as features:** `ThumbnailPipeline` (session-list previews), `QuickReply` (chips + palette) and `PointerContextMenu` were fully implemented and unit-tested with ZERO production call sites. All three are now wired. `ThumbnailPipeline`'s formal cross-review — explicitly skipped at the time, as the AW3 entry below admits — has finally been run. **Credential handling:** the `WEBTERM_TOKEN` cookie is now carried on REST and the WS upgrade, sealed with Tink AEAD under an AndroidKeyStore key, **fail-closed** (a seal failure persists nothing rather than falling back to plaintext), and `allowBackup=false` keeps a 30-day shell credential out of Google Drive and D2D transfer. **RESOLVED LATER THE SAME DAY** (this list was accurate when written; it is not any more): - **A34 / A35 now exist and RUN.** `:app/src/androidTest/**` holds 67 instrumented tests — the four crash regressions, the A34 E2E set (attach→output, ring-buffer replay, spawn failure via a second deliberately-broken server, a gate resolved through `POST /hook/decision`, and the F9 bad-Origin rejection), and the Compose UI tests plan §7 named — plus `macrobenchmark/` for A35. All 67 execute green on `emulator-5554` against this repo's own server. Reaching the host needs `ALLOWED_ORIGINS=http://10.0.2.2:` (the server derives allowed origins from NIC IPs, and 10.0.2.2 is the emulator's alias for the host, not an interface address). - **Device QA is no longer ~0%.** The blocker fixes are device-verified, not just JVM-verified. - Release signing, versioning and minification are done: `assembleRelease` runs R8 and now REFUSES to emit an unsigned artifact instead of quietly producing one. - **Copy-out is done** — a first-party selection layer over `TerminalBuffer.getSelectedText` + `ClipboardManager`, since the stock ActionMode's Copy handler dereferences the absent session. - The six audit leftovers are closed too: silent certificate rotation (+ `/device/:id/recover`), the W2 inject-queue routes, unread-watermark persistence, host-removal UI, and `/config/ui` finally honoured (fail-closed). **Still genuinely open:** - **S2** — the FCM real-handset delivery spike. Needs ≥2 physical devices under Doze/OEM battery managers; an emulator cannot answer it. - One instrumented test (`wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch`) was seen to kill the test process when the suite was driven WITHOUT the host arguments on a heavily-recycled emulator. It passes reproducibly under the documented invocation on a clean install. Re-check on real hardware before trusting it either way. - Everything else device-observable in `DEVICE_QA_CHECKLIST.md` that no automated test covers. --- ## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1–A36 + S1) landed Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure JVM) + `:app :terminal-view :host-registry :client-tls-android` (framework). The Android app **builds to an APK** with functional parity to the iOS P0+P1 scope; the Termux renderer seam (the plan's dominant risk) is proven working headless. S2 (FCM real-device delivery) is the one spike inherently requiring ≥2 physical handsets — documented in the checklist. Everything device-observable is deferred to `DEVICE_QA_CHECKLIST.md` per plan §7 (no emulator/Firebase/host in this env). **Method:** every wave ran a Workflow (TDD builders → 2-lens adversarial cross-review → fix workflow → adversarial re-verify → orchestrator-independent gate). Cross-validation caught + fixed ~20 real defects before they reached a screen (transport socket leaks, engine close-during-dial + gate double-send, an FCM token-refresh regression, a security bug where a REMOVED client cert stayed live in TLS, cross-session event bleed, blank-terminal-on-rotation, terminal-freeze-on-hostile-bytes, cold-start deep-link drop, …). **GIT / not committed:** all Android work is on-disk + test-verified but UNCOMMITTED — a concurrent session was running the tunnel-automation workstream on this same `feat/tunnel-automation` working tree, so the orchestrator held ALL git ops and never touched `docs/PROGRESS_LOG.md`. **TODO for the user:** reconcile the two sessions, then commit the Android lane by explicit path and fold this file into `docs/PROGRESS_LOG.md`. ### Tracked APP-ASSEMBLY items (a final wiring pass composes screens into the NavGraph + connects ### callbacks): away-digest expand → TimelineSheet (A28); host menu → pairing/cert (A20→A19/A27); ### project open → terminal (A23→A21); continue-last → terminal (A29); session row → terminal (A20→A21); ### and the A21 reflection shim → `libs.termux.terminal.view` on `:app` (dep now available to add). Cross-review of A21/A22 surfaced interlocking frozen-seam gaps being fixed together: (1 HIGH) `controller.events` was a single mailbox but banner+gate both collect → event split → made multi-consumer (`controlEvents()` per consumer); (2 HIGH) `holder.generation` made Compose-observable (mutableIntStateOf) so a real-background bump re-keys the AndroidView; (3 HIGH) RemoteTerminalSession/ controller moved into the config-surviving RetainedSessionHolder so §6.6 rotation keeps the emulator+ scrollback; (4) A22 gate surfaces composed into TerminalScreen; (5) warmUp error handling. A21 also flagged for later: the reflection shim to reach the impl-scoped Termux `TerminalView` from `:app` (clean fix = add `libs.termux.terminal.view` to `:app`); adopted-session-id survival across real-background → A29's LastSessionStore. ### Remaining AW4 screens (independent of the terminal path, next batches): A19 Pairing (QR/confirm/tiers) · A20 Session list+dashboard+host menu · A23 Projects+grouping+detail · A24 Diff viewer · A25 Quick-reply chips+palette · A27 ClientCertScreen · A28 Timeline sheet (wires to A22 away-digest expand) · A29 Cold-start UX (ColdStartPolicy + LastSessionStore lifecycle) · A26 Adaptive large-screen (LAST — deps A20+A21). - **[ ] AW2** A15 wiring/DI freeze (carry the A14 scope-close follow-up above). - **[ ] AW3** A16/A17/A18 · **[ ] AW4** A19–A29 · **[ ] AW5** A30–A32 · **[ ] AW6** A34–A36.