The correction section written earlier today said A34/A35 had no code and device QA was ~0%. Both were true when written and are not any more, so they are marked resolved rather than left to mislead the next reader — with the instrumented invocation recorded (the emulator needs ALLOWED_ORIGINS=http://10.0.2.2:<port>, because allowed origins are derived from NIC IPs and 10.0.2.2 is not one). What stays open is stated plainly: S2 needs two physical handsets under Doze, the rest of DEVICE_QA_CHECKLIST has no automated cover, and one instrumented test kills the process when driven without host arguments on a heavily-recycled emulator while passing reproducibly under the documented invocation — which is recorded as unresolved rather than explained away.
510 lines
40 KiB
Markdown
510 lines
40 KiB
Markdown
# 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<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 (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:<port>` (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.
|