Files
web-terminal/android/PROGRESS_ANDROID.md
Yaojia Wang e254918b1c
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
feat(android): native Android client — full app parity build (A1–A36 + S1)
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.
2026-07-10 16:41:21 +02:00

417 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.