feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated): :wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec), :session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client, :client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework: :app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless), :host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink). Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver, Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10); config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers. Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35, S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
This commit is contained in:
416
android/PROGRESS_ANDROID.md
Normal file
416
android/PROGRESS_ANDROID.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Android Client — Progress (this-session working log)
|
||||
|
||||
> **Why this file exists (temporary):** `docs/PROGRESS_LOG.md` (the canonical cross-session
|
||||
> memory) was being **concurrently edited by another live session** (the tunnel-automation
|
||||
> workstream on branch `feat/tunnel-automation`) while this Android work ran on the SAME working
|
||||
> tree. To avoid a read-modify-write clobber of that session's log, the Android orchestrator
|
||||
> records progress here instead. **FOLD THESE ENTRIES INTO `docs/PROGRESS_LOG.md` once the
|
||||
> concurrent session is done** (they belong under the `🤖 ANDROID CLIENT` section).
|
||||
>
|
||||
> **Git status note:** nothing below is committed. The Android lane (`android/**`,
|
||||
> `src/push/fcm.ts`, `test/push-fcm.test.ts`, `src/server.ts` FCM wiring, `package.json`
|
||||
> google-auth-library) is file-disjoint from the tunnel work, but the shared `.git`/index means
|
||||
> **no `git add -A`** — commit the Android lane by explicit path once branch strategy is settled.
|
||||
|
||||
Plan: [`../docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md). 36 tasks, waves 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).
|
||||
|
||||
---
|
||||
|
||||
## ✅ 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.
|
||||
Reference in New Issue
Block a user