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.
40 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).
⚠️ 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.
- Every key press crashed.
RemoteTerminalViewbound the forked emulator but never installed aTerminalViewClient. The pinned TermuxTerminalViewdereferencesmClientwith no null guard on the first line of the paths a user actually hits (onKeyDownoffset 82,onCreateInputConnectionoffset 0,onKeyUp,onKeyPreIme). The KDoc claimed "A17 sets it" — A17 never did; only theHardwareKeyRouterand the KeyBar shipped, and the KeyBar works precisely because it bypasses the view. Installing a client is not sufficient: returning false continues intomTermSession.write()at offset 150, andmTermSessionis null forever (TerminalSessionis final and forking a process is exactly what this app must not do, §6.1). Fixed by a client that consumes the key itself, withKeyRoutingdeliberately having NO defer-to-stock branch, so the crash is unrepresentable rather than merely avoided.KeyHandlerremains the authority for the key tables, so DECCKM still emitsESC O A. - No
resizewas ever sent.RemoteTerminalSession.updateSizehad zero call sites and the stock fallback is dead (TerminalView.updateSizeearly-returns without a session), so the PTY stayed at the server's 80x24 and every full-screen TUI rendered into the wrong box.TerminalResizeDrivernow drives it from real cell metrics, read via the PUBLICTerminalRenderer.getFontWidth()/getFontLineSpacing()accessors — no reflection (silently breaks under R8) and no re-measuredPaint(drifts from what the renderer actually draws, the §R5 risk). - Bare-LAN connections were impossible.
network_security_config.xmlwas a self-labelled STUB denying all cleartext, whileHostEndpointacceptshttpand derivesws://. 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
TerminalViewCliententirely:doScrollconverts a scroll tohandleKeyCodewhenever the alternate buffer is active, which dereferencesmTermSession. Claude Code IS an alternate-screen TUI, so this was a crash during ordinary use on the app's main screen.TerminalScrollGesturenow claims the vertical drag first and reproduces all threedoScrollbranches, closing the fling runnable andonGenericMotionEventwith it. autofill()dereferencesmTermSessionunguarded 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.onActionItemClickedoffsets 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 overTerminalBuffer+ClipboardManageris 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 throughPOST /hook/decision, and the F9 bad-Origin rejection), and the Compose UI tests plan §7 named — plusmacrobenchmark/for A35. All 67 execute green onemulator-5554against this repo's own server. Reaching the host needsALLOWED_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:
assembleReleaseruns 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/uifinally 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.mdthat 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.