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.
33 KiB
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 branchfeat/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 INTOdocs/PROGRESS_LOG.mdonce the concurrent session is done (they belong under the🤖 ANDROID CLIENTsection).Git status note: nothing below is committed. The Android lane (
android/**,src/push/fcm.ts,test/push-fcm.test.ts,src/server.tsFCM wiring,package.jsongoogle-auth-library) is file-disjoint from the tunnel work, but the shared.git/index means nogit add -A— commit the Android lane by explicit path once branch strategy is settled.
Plan: ../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. newtest/push-fcm.test.ts60 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 frozenTermTransport/PingableTermTransport/HttpTransportverbatim (contracts unmodified). - WS:
connect()stampsOrigin = endpoint.originHeaderon the upgrade (CSWSH; asserted byte-equal);frames=channelFlowdraining 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). SharedOkHttpClient.cache(null)(§8) + default system trust; mTLS via a LOCALClientIdentityProviderfun-interface seam (module depends only on:wire-protocol, no:client-tlsedge). - Cross-review fixes (regression-tested): (1) HIGH — handshake socket leak on mid-dial cancel:
openConnectionnow tears down the just-created WS on any throwable (incl.CancellationException) via an idempotentcancel()(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), andSessionEnginealready self-measures frames and emitsREPLAY_TOO_LARGE— the single authoritative classifier. (3) MEDIUM — pump rethrowsCancellationException(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 injectedTermTransport+ 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 theclosedflag 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 —startedflip moved onto the confined dispatcher (invariant #4). (3) MEDIUM — suspend-callrunCatchingreplaced with cancel-rethrowingignoringNonCancel. - 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 explicitengine.close()(per §6.6), so theRetainedSessionHolderMUST callengine.close()before cancelling the engine scope; additionally consider afinally-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(NotifyServiceimpl behind a seam) +POST/DELETE /push/fcm-tokenroute +initFcm/normalizeFcmTokenwiring insrc/server.ts(combined viacombineNotifyServicesbeside web-push/APNs — zero new event paths) +FCM_*env (all-or-disabled) +test/push-fcm.test.ts(60 tests). Addedgoogle-auth-library@^10topackage.jsondeps (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 whenFCM_*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 wrapsclient.requestin try/catch, reading{status,body}fromGaxiosError.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:testDebugUnitTestgreen): 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 (installedplatforms;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 throughMaterialTheme.colorScheme.{onSurfaceVariant, surfaceVariant,outline}(Theme.kt already maps those). Also fixed preview.dpliterals →Spacing.*. Docs synced:android/README.md+settings.gradle.ktsrecipe +[[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.
- androidTest
- 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. ExposesIdentityRepository/ClientSslMaterialas the seam A15 bridges to:transport-okhttp'sClientIdentityProvider(NO:transport-okhttpdep). - 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()usedliveOverride?.value ?: initialIdentitywhich collapsedBox(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()switchedapply()→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 regressionremove_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:
DataStoreLastSessionStorenow guards the persisted id with the frozen v4Validation.isValidSessionId(wasUUID.fromStringformat-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-streamsoutputBytes(): 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: A11IdentityRepository→ A7ClientIdentityProvider→ ONE sharedOkHttpClient(.cache(null), WS+REST) — construction cycle broken via a sharedConnectionPool. - Coordination change (authorized):
SessionEngine.close()now returns itsJobso teardown canjoin()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 byEventBus.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 orderedChannel<TerminalCommand>(Feed/Resize/Bind/Unbind); append chunked 4 KiB + yield();onScreenUpdatedposted to an injected main dispatcher. pendingOutput queued-then-flushed in order (ESC[0m preserved). Resize math extracted as pureTerminalGridMath(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/TerminalSessionarefinal→RemoteTerminalViewis a COMPOSITION wrapper binding the forked emulator via the publicmEmulatorfield (rendering stays 100% stock). Documented. - Cross-review (correctness approved; threading blocked→fixed): HIGH — bind published
mEmulatorto 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 — addedkotlinx-coroutines-android(elseDispatchers.Maincrashes on-device). Verified::terminal-view:assembleDebug+testDebugUnitTest17/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.fromBaseUrlvalidator; 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
LaunchedEffectimport (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
/prefsunknown-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-androidIdentityRepository; 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.Translucentapplied by orchestrator;:appassembles.
[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
DeepLinkEffectinside theroute != nullbranch so it runs only afterWebTermNavHostcomposed (graph set). Re-verified:app227 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
koverVerifyminBound(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 koverVerifyGREEN. - [x] Device-QA checklist:
android/DEVICE_QA_CHECKLIST.md— captures A34 (instrumented E2E vs realnpm 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:appAPK +:terminal-view/:host-registry/:client-tls-androidall assemble. Server side (A33 FCM) unchanged since R1 (1533 server tests green).
✅ 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.