feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled

Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated):
:wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec),
:session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client,
:client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework:
:app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux
terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless),
:host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink).

Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading
X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed
rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver,
Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10);
config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers.

Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all
framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35,
S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no
emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial
cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
This commit is contained in:
Yaojia Wang
2026-07-10 16:41:21 +02:00
parent 542fde9580
commit e254918b1c
159 changed files with 20114 additions and 73 deletions

416
android/PROGRESS_ANDROID.md Normal file
View File

@@ -0,0 +1,416 @@
# 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 AW0AW6.
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<ByteArray>` (Output-only, UTF-8 decode off-Main via flowOn) +
`controlEvents(): Flow<SessionEvent>` (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<TerminalCommand>` (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<Key,Deferred<Bitmap>>` 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 (A19A29)
### [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 (A19A29) 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).
---
## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1A36 + 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** A19A29 · **[ ] AW5** A30A32 · **[ ] AW6** A34A36.