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:
76
android/DEVICE_QA_CHECKLIST.md
Normal file
76
android/DEVICE_QA_CHECKLIST.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Android client — device-QA checklist (A36 / plan §7)
|
||||
|
||||
Everything below is **device/emulator-only** — it could NOT run in the build environment (no emulator,
|
||||
no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests,
|
||||
Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping.
|
||||
|
||||
## Deploy artifacts to provide first (not built here)
|
||||
- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its
|
||||
absence with `runCatching`, so the app runs without it; push just won't register).
|
||||
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
|
||||
omitted it — applying it without the json fails the build).
|
||||
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
|
||||
SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the
|
||||
`FCM_*` env group (service-account key path + project id).
|
||||
|
||||
## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device)
|
||||
- [ ] `attach → attached → output` round-trip timing.
|
||||
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
|
||||
- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy.
|
||||
- [ ] kill via `DELETE /live-sessions/:id` (swipe-to-kill) removes the row (404 = already-gone = success).
|
||||
- [ ] `POST /hook/decision` resolves a held gate (from a push Allow/Deny).
|
||||
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
|
||||
|
||||
## A35 — one macrobenchmark / Espresso happy path
|
||||
- [ ] pair → attach → type → approve, on a real device.
|
||||
|
||||
## Terminal (A16/A17/A21)
|
||||
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
|
||||
WebView fallback ONLY if fidelity diverges — not expected).
|
||||
- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap.
|
||||
- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are
|
||||
package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); resize parity vs
|
||||
web/iOS on the same device sizes (R5).
|
||||
- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay
|
||||
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
|
||||
generation bump → fresh emulator replays.
|
||||
- [ ] key-bar above the IME (soft keyboard never pops on a key-bar tap); hardware chords; DECCKM arrows
|
||||
emit `ESC O A` under app-cursor mode (vim/htop).
|
||||
- [ ] `FLAG_SECURE` + privacy cover blanks the recents thumbnail on `ON_STOP`.
|
||||
- [ ] off-main chunked append does not ANR on a multi-MB replay.
|
||||
|
||||
## Push (A30/A31 · R1/S2)
|
||||
- [ ] **S2 spike:** data-only high-priority delivery latency/loss on ≥2 real handsets (incl. one
|
||||
Xiaomi/Huawei/Samsung) under Doze / OEM battery managers / force-stop — quantify loss, drive the
|
||||
"delivery not guaranteed while backgrounded" user-facing copy.
|
||||
- [ ] **Deny** from the lock screen (BroadcastReceiver, no app open, expedited POST).
|
||||
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
|
||||
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
|
||||
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
|
||||
- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals
|
||||
on rotation / new host / removal.
|
||||
|
||||
## Pairing / cert / storage (A19/A27/A11/A12)
|
||||
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry.
|
||||
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
|
||||
bypass); public host explicit-ack.
|
||||
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
|
||||
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
|
||||
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
|
||||
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
||||
|
||||
## Nav / deep links / adaptive (A32/A26/A29)
|
||||
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
||||
right destination; invalid UUID ignored.
|
||||
- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens.
|
||||
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
||||
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
||||
|
||||
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
|
||||
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
|
||||
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
|
||||
- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
|
||||
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
|
||||
- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView`
|
||||
is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to
|
||||
`:app` and delete the shim.
|
||||
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.
|
||||
@@ -103,7 +103,10 @@ AGP library module compiled against SDK 35 and produced an AAR):
|
||||
|
||||
- **SDK location:** `/usr/local/share/android-commandlinetools`
|
||||
(installed via `brew install --cask android-commandlinetools`).
|
||||
- **Installed packages:** `platform-tools`, `platforms;android-35`, `build-tools;35.0.0`.
|
||||
- **Installed packages:** `platform-tools`, `platforms;android-35`, `platforms;android-36`,
|
||||
`build-tools;35.0.0`, `build-tools;36.0.0`. (`:app` compiles against SDK **36** — the
|
||||
Kotlin-2.3.21-contemporaneous androidx/Compose line refuses SDK 35; `platforms;android-37`
|
||||
is not fetchable here as the cmdline-tools are too old to parse the v4 repo XML.)
|
||||
- **`android/local.properties`** (gitignored) points Gradle at it:
|
||||
`sdk.dir=/usr/local/share/android-commandlinetools`.
|
||||
- **Shell env** (for `sdkmanager`/`adb`): `export ANDROID_HOME=/usr/local/share/android-commandlinetools`.
|
||||
@@ -116,7 +119,15 @@ AGP library module compiled against SDK 35 and produced an AAR):
|
||||
compatible with Gradle 9.6.1.
|
||||
- **Gotcha:** AGP 9 has **built-in Kotlin** — apply ONLY the android plugin. Adding
|
||||
`org.jetbrains.kotlin.android` errors with "no longer required since AGP 9.0".
|
||||
- Module block: `android { namespace = "…"; compileSdk = 35; defaultConfig { minSdk = 29 } }`.
|
||||
- Module block: `android { namespace = "…"; compileSdk = 36; defaultConfig { minSdk = 29 } }`.
|
||||
(Framework modules target `compileSdk = 36`; `targetSdk` stays `35` per plan §2.)
|
||||
- **`:app` UI-stack version matrix** (A13, proven `:app:assembleDebug` green): AGP 9.2.1 ·
|
||||
Kotlin 2.3.21 · Compose-compiler plugin `org.jetbrains.kotlin.plugin.compose` = 2.3.21 ·
|
||||
Compose BOM `2025.11.01` (→ material3 1.4.0, ui/foundation 1.9.5, material3.adaptive 1.2.0,
|
||||
material3-adaptive-navigation-suite 1.4.0) · Hilt (dagger) 2.60.1 via KSP `2.3.9` ·
|
||||
androidx core-ktx 1.17.0 / activity-compose 1.12.4 / lifecycle 2.10.0. Apply plugins:
|
||||
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
|
||||
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
|
||||
|
||||
To add more SDK pieces later (e.g. an emulator image for instrumented tests):
|
||||
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kover)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -25,3 +26,14 @@ dependencies {
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// A36 acceptance gate: >=80% line coverage on this pure module (plan §7).
|
||||
kover {
|
||||
reports {
|
||||
verify {
|
||||
rule {
|
||||
minBound(80)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ public data class LiveSessionInfo(
|
||||
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
|
||||
val exited: Boolean,
|
||||
val cwd: String? = null,
|
||||
/** Tab title / derived label (e.g. 'claude', 'shell'), OSC-title-derived server-side (`src/types.ts`
|
||||
* `title?`). HOST/ATTACKER-influenced — run through `TitleSanitizer` before any UI/list use (§8).
|
||||
* Additive optional field; absent → `null`. */
|
||||
val title: String? = null,
|
||||
/** Current PTY size (latest-writer-wins on the server). */
|
||||
val cols: Int,
|
||||
val rows: Int,
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// :app — the Android application (Compose UI, ViewModels, Hilt DI, FCM push,
|
||||
// DeepLinkRouter, DesignSystem, EventBus). Mirrors iOS App/WebTerm.
|
||||
// :app — the Android application (Compose Material 3 + Adaptive design system,
|
||||
// Hilt DI skeleton, launcher MainActivity). Mirrors iOS App/WebTerm.
|
||||
//
|
||||
// SCAFFOLD STUB ONLY. This module is COMMENTED OUT in settings.gradle.kts because
|
||||
// this build environment has NO Android SDK. The block below is the intended shape;
|
||||
// it applies the Android Gradle Plugin, which cannot resolve without an SDK.
|
||||
// A13 establishes the Android UI-stack version matrix for every later Android task
|
||||
// (the app-stack baseline, analogous to how A1 established the JVM catalog). All
|
||||
// UI versions are resolved in gradle/libs.versions.toml.
|
||||
//
|
||||
// TODO(android-sdk): uncomment the include in settings.gradle.kts AND this block,
|
||||
// add AGP + google() to pluginManagement, and provide local.properties -> sdk.dir.
|
||||
// Plugins (AGP 9 has BUILT-IN Kotlin → never apply org.jetbrains.kotlin.android):
|
||||
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/*
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.gms.google-services")
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "wang.yaojia.webterm"
|
||||
compileSdk = 35
|
||||
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
|
||||
// Kotlin 2.3.21 requires compiling against SDK 36+ (BOM 2025.11 → ui 1.9.5).
|
||||
// targetSdk stays 35 per plan §2 (compileSdk ≥ targetSdk is the normal rule).
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "wang.yaojia.webterm"
|
||||
@@ -31,18 +32,91 @@ android {
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildFeatures { compose = true }
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// No applicationId suffix — keep it stable for deep-link testing (A32).
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin { jvmToolchain(17) }
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Modules A15 wires into the DI graph.
|
||||
implementation(project(":wire-protocol"))
|
||||
implementation(project(":session-core"))
|
||||
implementation(project(":api-client"))
|
||||
implementation(project(":transport-okhttp"))
|
||||
implementation(project(":client-tls"))
|
||||
// A15: bridge the mTLS device identity (:client-tls-android) onto the shared client, and
|
||||
// provide the DataStore-backed stores (:host-registry). :client-tls-android exposes
|
||||
// :client-tls via `api`, but the explicit dep above is kept for clarity.
|
||||
implementation(project(":client-tls-android"))
|
||||
implementation(project(":host-registry"))
|
||||
// Terminal render (A16): RemoteTerminalSession/RemoteTerminalView for the live terminal (A21) and
|
||||
// the raw Termux TerminalEmulator for A18's off-screen thumbnail rasterisation (§6.7). :terminal-view
|
||||
// scopes Termux as `implementation`, so :app names the emulator directly for the off-screen path.
|
||||
implementation(project(":terminal-view"))
|
||||
implementation(libs.termux.terminal.emulator)
|
||||
// QR pairing (A19): CameraX + on-device ML Kit barcode scanning.
|
||||
implementation(libs.bundles.camerax)
|
||||
implementation(libs.mlkit.barcode.scanning)
|
||||
// Push (A30) + nav/deep-links (A32).
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.messaging)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
// OkHttp is `implementation` (not `api`) in :transport-okhttp, so :app names OkHttpClient /
|
||||
// ConnectionPool directly in NetworkModule/TlsModule.
|
||||
implementation(libs.okhttp)
|
||||
// Coroutines are used directly by the wiring (EventBus fan-out, engine confinement scope).
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
// Preferences DataStore is named in StorageModule's @Provides return types.
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
// AndroidX foundation
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// Compose (the BOM governs every version below)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.bundles.compose)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Hilt (Dagger) — KSP annotation processing
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
// JVM unit tests (no device) — the frozen design-token spec, EventBus fan-out, and the
|
||||
// RetainedSessionHolder lifecycle invariant driven by a fake transport under virtual time.
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules.
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
*/
|
||||
|
||||
4
android/app/proguard-rules.pro
vendored
Normal file
4
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# WebTerm :app — R8/ProGuard rules.
|
||||
# Release is not minified yet (isMinifyEnabled = false); this file exists so the
|
||||
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for
|
||||
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later.
|
||||
74
android/app/src/main/AndroidManifest.xml
Normal file
74
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Talk to the WebTerm server over WS/HTTP. No NEARBY_WIFI_DEVICES: Android
|
||||
has no local-network permission prompt; plain WS to a LAN IP needs none
|
||||
(plan §8 / R12). -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Push (A30/A31) — requested with rationale at runtime on API 33+. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- QR pairing (A19) — CameraX preview; requested with rationale at runtime, denial
|
||||
degrades to manual URL entry. android:required=false so non-camera devices install. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".WebTermApp"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WebTerm"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="false">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.WebTerm">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- A32 deep links → DeepLinkRouter (v4-UUID whitelist; the manifest never validates). -->
|
||||
<!-- Custom scheme: webterminal://open?host=<uuid>&join=<uuid> -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="webterminal" android:host="open" />
|
||||
</intent-filter>
|
||||
<!-- Verified App Link: https://terminal.yaojia.wang/open?host=&join= .
|
||||
autoVerify needs assetlinks.json (release SHA-256) served at
|
||||
/.well-known/assetlinks.json — a deploy artifact (§8), not built here. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="terminal.yaojia.wang" android:pathPrefix="/open" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Push (A30) — FCM data-only entry point (server sends no notification block). -->
|
||||
<service
|
||||
android:name=".push.FcmService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<!-- Deny action: auth-free, no-UI, expedited POST (goAsync). -->
|
||||
<receiver
|
||||
android:name=".push.DenyBroadcastReceiver"
|
||||
android:exported="false" />
|
||||
<!-- Allow action: translucent, excluded-from-recents trampoline hosting BiometricPrompt
|
||||
(a receiver/service cannot present it — R1). -->
|
||||
<activity
|
||||
android:name=".push.AllowTrampolineActivity"
|
||||
android:theme="@style/Theme.WebTerm.Translucent"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
176
android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt
Normal file
176
android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt
Normal file
@@ -0,0 +1,176 @@
|
||||
package wang.yaojia.webterm
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.nav.DeepLinkRouter
|
||||
import wang.yaojia.webterm.nav.NavRoutes
|
||||
import wang.yaojia.webterm.nav.WebTermNavHost
|
||||
import wang.yaojia.webterm.push.PushCoordinator
|
||||
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The single launcher Activity and composition root. A `@AndroidEntryPoint` [FragmentActivity]
|
||||
* (FragmentActivity so the biometric/allow trampoline hierarchy is consistent) that:
|
||||
*
|
||||
* - **warms the mTLS/OkHttp stack off `Main`** ([AppEnvironment.warmUp]) before any terminal bind;
|
||||
* - **registers this device's FCM token with every paired host on app start** ([PushCoordinator]);
|
||||
* - **hands the launch/new intents to [DeepLinkRouter]** (the ONE whitelist parser) and navigates only a
|
||||
* validated [NavRoutes.forDeepLink] route — the manifest `<intent-filter>`s deliver, never validate;
|
||||
* - renders [WebTermNavHost] with the cold-start start destination ([AppEnvironment.coldStartPolicy]).
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
public class MainActivity : FragmentActivity() {
|
||||
|
||||
@Inject
|
||||
public lateinit var appEnvironment: AppEnvironment
|
||||
|
||||
@Inject
|
||||
public lateinit var pushCoordinator: PushCoordinator
|
||||
|
||||
/** The latest deep-link URI to route (null = nothing pending). Fed by onCreate + onNewIntent. */
|
||||
private val pendingDeepLink = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Build the shared OkHttp/mTLS stack OFF-Main before the first bind (warmUp hops to IO itself);
|
||||
// a real failure is swallowed (the terminal screen surfaces its own retry, FIX 5) but cancellation
|
||||
// is rethrown so a destroyed Activity tears the warm-up down cleanly.
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
appEnvironment.warmUp()
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
// best-effort pre-warm; TerminalScreen re-runs warmUp with an actionable retry.
|
||||
}
|
||||
}
|
||||
// App start: register the current FCM token with every paired host (best-effort self-heal).
|
||||
pushCoordinator.registerAllHosts()
|
||||
|
||||
handleDeepLinkIntent(intent)
|
||||
|
||||
setContent {
|
||||
WebTermTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
val navController = rememberNavController()
|
||||
NotificationPermissionGate()
|
||||
ColdStartHost(
|
||||
env = appEnvironment,
|
||||
navController = navController,
|
||||
pendingDeepLink = pendingDeepLink,
|
||||
onHostPaired = { host -> pushCoordinator.registerHost(host) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleDeepLinkIntent(intent)
|
||||
}
|
||||
|
||||
/** Capture a VIEW deep link's data URI; a launcher/push-body intent has none → nothing to route. */
|
||||
private fun handleDeepLinkIntent(intent: Intent?) {
|
||||
pendingDeepLink.value = intent?.data?.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the cold-start destination ([AppEnvironment.coldStartPolicy]) then render the graph. Shows a
|
||||
* spinner until the (suspend) host-presence read completes.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColdStartHost(
|
||||
env: AppEnvironment,
|
||||
navController: NavHostController,
|
||||
pendingDeepLink: MutableStateFlow<String?>,
|
||||
onHostPaired: (Host) -> Unit,
|
||||
) {
|
||||
val startRoute by produceState<String?>(initialValue = null, key1 = env) {
|
||||
value = NavRoutes.startRouteFor(env.coldStartPolicy.initialRoute())
|
||||
}
|
||||
val route = startRoute
|
||||
if (route == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
WebTermNavHost(
|
||||
env = env,
|
||||
startRoute = route,
|
||||
navController = navController,
|
||||
onHostPaired = onHostPaired,
|
||||
)
|
||||
// Route pending deep links ONLY here — inside the `route != null` branch, so `WebTermNavHost`
|
||||
// (which calls navController.setGraph) has composed and the graph is set before the effect body
|
||||
// runs. On a cold launch the VIEW intent is captured before setContent, but its navigation is
|
||||
// deferred to this point instead of racing an unset graph (which silently dropped the link).
|
||||
DeepLinkEffect(pending = pendingDeepLink, navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a pending deep-link URI through [DeepLinkRouter] (the ONE whitelist parser) and navigate only a
|
||||
* validated route; invalid/ambiguous links are ignored (never partially applied). Clears the pending
|
||||
* value once handled.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeepLinkEffect(
|
||||
pending: MutableStateFlow<String?>,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
val uri by pending.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(uri) {
|
||||
val target = uri ?: return@LaunchedEffect
|
||||
NavRoutes.forDeepLink(DeepLinkRouter.route(target))?.let { route ->
|
||||
runCatching { navController.navigate(route) }
|
||||
}
|
||||
pending.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Request POST_NOTIFICATIONS once on first launch (API 33+); a denial only stops push from being SHOWN. */
|
||||
@Composable
|
||||
private fun NotificationPermissionGate() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
LaunchedEffect(Unit) {
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
12
android/app/src/main/java/wang/yaojia/webterm/WebTermApp.kt
Normal file
12
android/app/src/main/java/wang/yaojia/webterm/WebTermApp.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package wang.yaojia.webterm
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
/**
|
||||
* Application entry point. `@HiltAndroidApp` triggers Hilt's code generation and
|
||||
* creates the app-level `SingletonComponent` (see `di/AppModule`). The real
|
||||
* composition root / wiring is A15 — this stays intentionally empty.
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
public class WebTermApp : Application()
|
||||
@@ -0,0 +1,92 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermCard
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.session.AwayDigest
|
||||
import wang.yaojia.webterm.wire.TimelineEvent
|
||||
|
||||
/**
|
||||
* # AwayDigestView (A22) — the "what happened while I was away" reattach summary.
|
||||
*
|
||||
* The Android analogue of the iOS away-digest banner (plan §1): shown ABOVE the terminal on the
|
||||
* first reconnect after a gap, summarising the [AwayDigest] reduced over the events since the user
|
||||
* left — tool-run and waiting counts plus done/stuck flags. Its **展开** ("expand") affordance opens
|
||||
* the A28 activity-timeline sheet via [onExpand] (this component only exposes the seam; A28 wires it).
|
||||
*
|
||||
* An [empty][AwayDigest.isEmpty] digest renders **nothing** (all-zero suppressed) — the caller
|
||||
* ([GateViewModel] holds `null` for an empty digest) normally never passes one, but the guard makes
|
||||
* the component safe in isolation. Recent-event labels are server-derived text rendered as **inert
|
||||
* [Text]** (no linkify/markdown, §8).
|
||||
*/
|
||||
@Composable
|
||||
public fun AwayDigestView(
|
||||
digest: AwayDigest,
|
||||
onExpand: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (digest.isEmpty) return // all-zero suppressed; render nothing.
|
||||
WebTermCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
Text(
|
||||
text = summaryLine(digest),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
digest.recent.lastOrNull()?.let { latest ->
|
||||
// INERT: server-derived phrase rendered verbatim, single line (§8).
|
||||
Text(
|
||||
text = latest.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = onExpand) { Text(text = "展开") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The one-line count summary, e.g. "离开期间:3 次工具调用 · 1 次等待 · 已完成". Pure presentation. */
|
||||
internal fun summaryLine(digest: AwayDigest): String {
|
||||
val parts = mutableListOf<String>()
|
||||
if (digest.toolRuns > 0) parts += "${digest.toolRuns} 次工具调用"
|
||||
if (digest.waitingCount > 0) parts += "${digest.waitingCount} 次等待"
|
||||
if (digest.sawDone) parts += "已完成"
|
||||
if (digest.sawStuck) parts += "疑似卡住"
|
||||
val body = if (parts.isEmpty()) "有活动" else parts.joinToString(" · ")
|
||||
return "离开期间:$body"
|
||||
}
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "AwayDigestView")
|
||||
@Composable
|
||||
private fun AwayDigestViewPreview() {
|
||||
WebTermTheme {
|
||||
AwayDigestView(
|
||||
digest = AwayDigest(
|
||||
toolRuns = 3,
|
||||
waitingCount = 1,
|
||||
sawDone = true,
|
||||
sawStuck = false,
|
||||
recent = listOf(TimelineEvent(at = 0L, eventClass = "tool", toolName = "Bash", label = "ran Bash")),
|
||||
),
|
||||
onExpand = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.Radius
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
|
||||
/**
|
||||
* # ContinueLastBanner (A29) — the "继续上次会话" re-entry affordance.
|
||||
*
|
||||
* The visible half of the continue-last-session cold-start UX (plan §1, §5 A29). When a host still has a
|
||||
* live last session ([LastSessionStore][wang.yaojia.webterm.hostregistry.LastSessionStore], kept current
|
||||
* by [SessionActivityBridge][wang.yaojia.webterm.wiring.SessionActivityBridge]), this banner is layered
|
||||
* on top of the sessions landing; tapping it re-opens THAT session (full scrollback replay) instead of
|
||||
* spawning a new one. It renders in two layouts:
|
||||
* - [ContinueLastVariant.Stack] — a full-width strip pinned above a stacked (phone) session list;
|
||||
* - [ContinueLastVariant.Sidebar] — a compact rounded card for the tablet nav sidebar.
|
||||
*
|
||||
* ### Shown IFF a last session exists
|
||||
* The pure [continueLastModel] maps the persisted last-session id to a [ContinueLastModel] (or `null`),
|
||||
* and the composable renders NOTHING for a `null` model — so "shown iff a last session exists" is the
|
||||
* single JVM-tested rule, no device needed. Layout/gesture polish is device-QA (plan §7).
|
||||
*
|
||||
* ### Inert rendering (plan §8)
|
||||
* The session id is a SERVER-issued (untrusted) string; it is shown as a plain, inert [Text] — no
|
||||
* Markdown / autolink / `AnnotatedString` linkify.
|
||||
*/
|
||||
|
||||
/** Which layout the banner renders in — a top strip (phone) or a sidebar card (tablet). */
|
||||
public enum class ContinueLastVariant { Stack, Sidebar }
|
||||
|
||||
/** The banner's derived state: the last session that can be re-opened. */
|
||||
public data class ContinueLastModel(val sessionId: String)
|
||||
|
||||
/**
|
||||
* PURE derivation: a non-blank persisted [lastSessionId] yields a [ContinueLastModel] (banner shown);
|
||||
* `null`/blank yields `null` (banner hidden). Blank is treated as absent so a corrupt persisted value
|
||||
* never renders an un-openable banner.
|
||||
*/
|
||||
public fun continueLastModel(lastSessionId: String?): ContinueLastModel? =
|
||||
lastSessionId?.takeIf { it.isNotBlank() }?.let { ContinueLastModel(it) }
|
||||
|
||||
/**
|
||||
* The banner. Renders NOTHING when [model] is `null` (no last session). Tapping the whole surface fires
|
||||
* [onContinue] with the session id to re-open. [variant] picks the stack vs sidebar layout.
|
||||
*/
|
||||
@Composable
|
||||
public fun ContinueLastBanner(
|
||||
model: ContinueLastModel?,
|
||||
onContinue: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
variant: ContinueLastVariant = ContinueLastVariant.Stack,
|
||||
) {
|
||||
if (model == null) return
|
||||
|
||||
val shape: Shape = when (variant) {
|
||||
ContinueLastVariant.Stack -> RectangleShape
|
||||
ContinueLastVariant.Sidebar -> RoundedCornerShape(Radius.md12)
|
||||
}
|
||||
val widthModifier =
|
||||
if (variant == ContinueLastVariant.Stack) Modifier.fillMaxWidth() else Modifier
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
shape = shape,
|
||||
modifier = modifier
|
||||
.then(widthModifier)
|
||||
.clickable { onContinue(model.sessionId) },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||
) {
|
||||
Text(text = "↻", style = MaterialTheme.typography.titleMedium)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs2),
|
||||
) {
|
||||
Text(text = "继续上次会话", style = MaterialTheme.typography.bodyMedium)
|
||||
// Untrusted server-issued id — inert Text, no autolink/markdown (§8).
|
||||
Text(
|
||||
text = shortSessionId(model.sessionId),
|
||||
style = WebTermType.metaMono,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(text = "›", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A short, inert label for the (untrusted) session id — the first id segment, capped so it never wraps. */
|
||||
private fun shortSessionId(sessionId: String): String =
|
||||
sessionId.substringBefore('-').take(SHORT_ID_MAX)
|
||||
|
||||
private const val SHORT_ID_MAX: Int = 12
|
||||
|
||||
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "ContinueLastBanner — stack")
|
||||
@Composable
|
||||
private fun ContinueLastBannerStackPreview() {
|
||||
WebTermTheme {
|
||||
ContinueLastBanner(
|
||||
model = ContinueLastModel("a1b2c3d4-5678-4abc-9def-000000000000"),
|
||||
onContinue = {},
|
||||
variant = ContinueLastVariant.Stack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "ContinueLastBanner — sidebar", widthDp = 220)
|
||||
@Composable
|
||||
private fun ContinueLastBannerSidebarPreview() {
|
||||
WebTermTheme {
|
||||
ContinueLastBanner(
|
||||
model = ContinueLastModel("a1b2c3d4-5678-4abc-9def-000000000000"),
|
||||
onContinue = {},
|
||||
variant = ContinueLastVariant.Sidebar,
|
||||
modifier = Modifier.padding(Spacing.md12),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.DisplayStatus
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.StatusBadge
|
||||
import wang.yaojia.webterm.designsystem.WebTermCard
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.session.GateState
|
||||
import wang.yaojia.webterm.viewmodels.GateDecision
|
||||
import wang.yaojia.webterm.wire.GateKind
|
||||
|
||||
/**
|
||||
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
|
||||
*
|
||||
* The Android analogue of the iOS tool-gate card (public/tabs.ts:334-350): a `WebTermCard` holding
|
||||
* a "needs me" status badge, the server-supplied [detail][GateState.detail] rendered as **inert
|
||||
* [Text]** (untrusted OSC/tool text — no Markdown / autolink, plan §8), and two actions: **批准**
|
||||
* (approve → `Approve(mode=null)`) and **拒绝** (reject).
|
||||
*
|
||||
* ### Epoch capture (the stale-guard seam)
|
||||
* Each button hands back the [epoch][GateState.epoch] of the gate IT rendered, so a tap is bound to
|
||||
* the gate the user actually saw. The screen (A21) wires `onDecide = { d, e -> vm.decide(d, e) }`;
|
||||
* [GateViewModel.decide] then drops the tap if that epoch is no longer the live gate.
|
||||
*
|
||||
* This card is for [GateKind.TOOL] gates only; plan gates use [PlanGateSheet] (three-way).
|
||||
* Layout / colors are device-QA; the wired decision + epoch capture is the load-bearing contract.
|
||||
*
|
||||
* @param onDecide invoked with the tapped [GateDecision] and this gate's epoch.
|
||||
*/
|
||||
@Composable
|
||||
public fun GateBanner(
|
||||
gate: GateState,
|
||||
onDecide: (GateDecision, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
WebTermCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
StatusBadge(status = DisplayStatus.PendingApproval, showsLabel = true)
|
||||
gate.detail?.let { detail ->
|
||||
// INERT: untrusted server string rendered verbatim, single-line, no linkify (§8).
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
|
||||
Text(text = "拒绝")
|
||||
}
|
||||
Button(onClick = { onDecide(GateDecision.APPROVE, gate.epoch) }) {
|
||||
Text(text = "批准")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "GateBanner")
|
||||
@Composable
|
||||
private fun GateBannerPreview() {
|
||||
WebTermTheme {
|
||||
GateBanner(
|
||||
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
|
||||
onDecide = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.LayoutTokens
|
||||
import wang.yaojia.webterm.designsystem.Opacity
|
||||
import wang.yaojia.webterm.designsystem.Radius
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.session.KeyByteMap
|
||||
|
||||
/**
|
||||
* # KeyBar (A17) — the mobile IME-bypass touch key-bar + hardware-chord router.
|
||||
*
|
||||
* The Android port of `public/keybar.ts`: a horizontal strip of the Claude Code
|
||||
* high-frequency keys a phone soft keyboard can't easily produce (Esc / Esc² /
|
||||
* ⇧Tab / arrows / Enter / Ctrl-chords / Tab / `/`), most-used first.
|
||||
*
|
||||
* ### Byte source of truth (plan §6.3, A6)
|
||||
* Every label→bytes lookup — buttons AND hardware chords — resolves through
|
||||
* [KeyByteMap] (byte-for-byte matched to the web `KEY_MAP`). NO escape sequence is
|
||||
* ever hand-written here; hand-writing `ESC[A` etc. is a review finding.
|
||||
*
|
||||
* ### IME bypass (plan §6.3 source #2, R8)
|
||||
* A key-bar tap calls [emitKey] → `onSend(bytes)` DIRECTLY, mirroring the web
|
||||
* `ws.send` bypass. It never routes through a `BasicTextField` / the emulator's
|
||||
* `InputConnection`, so tapping a key never pops the soft keyboard. `onSend` is
|
||||
* wired by A21 to `TerminalSessionController.sendInput`.
|
||||
*
|
||||
* ### DECCKM split (plan §6.3 source #3)
|
||||
* Hardware keys are routed by [HardwareKeyRouter]: Esc / Ctrl-<letter> / ⇧Tab are
|
||||
* mapped app-side via [KeyByteMap]; **arrows / Enter / (plain) Tab are LEFT to
|
||||
* Termux's `KeyHandler`** (via `RemoteTerminalView.onKeyCommand` returning false)
|
||||
* so `cursorKeysApplication` (DECCKM) still emits `ESC O A` — never `ESC [ A` —
|
||||
* for vim/htop.
|
||||
*
|
||||
* Compose layout / IME-inset behaviour / real key events are DEVICE-QA (plan §7);
|
||||
* the byte contract + the routing decision are the JVM-tested core.
|
||||
*/
|
||||
|
||||
/** One key-bar button: fixed glyph + Chinese caption + the [KeyByteMap.Key] it emits. */
|
||||
public data class KeyBarButton(
|
||||
val label: String,
|
||||
val caption: String,
|
||||
val key: KeyByteMap.Key,
|
||||
val title: String,
|
||||
val isPrimary: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Button layout — most-used Claude Code keys first, mirroring the web
|
||||
* `KEYBAR_BUTTONS` (public/keybar.ts) order, glyphs and captions 1:1. The 🎤 voice
|
||||
* button (web A2) is out of scope for A17.
|
||||
*/
|
||||
public val KEYBAR_LAYOUT: List<KeyBarButton> = listOf(
|
||||
KeyBarButton("Esc", "中断", KeyByteMap.Key.ESC, "Esc — interrupt Claude / dismiss", isPrimary = true),
|
||||
KeyBarButton("Esc²", "回溯", KeyByteMap.Key.ESC_ESC, "Esc Esc — rewind / clear draft"),
|
||||
KeyBarButton("⇧Tab", "模式", KeyByteMap.Key.SHIFT_TAB, "Shift+Tab — cycle plan / auto-accept mode"),
|
||||
KeyBarButton("↑", "上一个", KeyByteMap.Key.ARROW_UP, "Up — previous option / history"),
|
||||
KeyBarButton("↓", "下一个", KeyByteMap.Key.ARROW_DOWN, "Down — next option / history"),
|
||||
KeyBarButton("⏎", "确认", KeyByteMap.Key.ENTER, "Enter — confirm"),
|
||||
KeyBarButton("^C", "取消", KeyByteMap.Key.CTRL_C, "Ctrl+C — cancel / quit"),
|
||||
KeyBarButton("^R", "搜历史", KeyByteMap.Key.CTRL_R, "Ctrl+R — reverse-search command history"),
|
||||
KeyBarButton("^O", "详情", KeyByteMap.Key.CTRL_O, "Ctrl+O — expand transcript / tool detail"),
|
||||
KeyBarButton("^L", "重绘", KeyByteMap.Key.CTRL_L, "Ctrl+L — redraw screen"),
|
||||
KeyBarButton("^T", "任务", KeyByteMap.Key.CTRL_T, "Ctrl+T — toggle task list"),
|
||||
KeyBarButton("^B", "后台", KeyByteMap.Key.CTRL_B, "Ctrl+B — background running task"),
|
||||
KeyBarButton("^D", "退出", KeyByteMap.Key.CTRL_D, "Ctrl+D — exit session (EOF)"),
|
||||
KeyBarButton("Tab", "补全", KeyByteMap.Key.TAB, "Tab — complete / toggle"),
|
||||
KeyBarButton("←", "左移", KeyByteMap.Key.ARROW_LEFT, "Left — move cursor left"),
|
||||
KeyBarButton("→", "右移", KeyByteMap.Key.ARROW_RIGHT, "Right — move cursor right"),
|
||||
KeyBarButton("/", "命令", KeyByteMap.Key.SLASH, "Slash — command launcher"),
|
||||
)
|
||||
|
||||
/**
|
||||
* The single tap→wire funnel: resolve [key]'s bytes through [KeyByteMap] and hand
|
||||
* them VERBATIM to [sink] (invariant #9 — no content filtering). This is exactly
|
||||
* what a button's `onClick` invokes, and what the JVM byte-contract test drives.
|
||||
*/
|
||||
internal fun emitKey(key: KeyByteMap.Key, sink: (String) -> Unit) {
|
||||
sink(KeyByteMap.bytes(key))
|
||||
}
|
||||
|
||||
/** Web parity: the key-bar is a mobile affordance, hidden on wide screens (web hides it >768px). */
|
||||
internal const val WIDE_SCREEN_MAX_DP: Int = 768
|
||||
|
||||
/**
|
||||
* Whether the key-bar should render: shown on narrow (phone-width) screens only,
|
||||
* AND hidden when a hardware keyboard is present (the physical keys make the
|
||||
* touch bar redundant — the iPad-equivalent auto-hide, plan §"iPad-equivalent").
|
||||
*/
|
||||
internal fun shouldShowKeyBar(screenWidthDp: Int, hardwareKeyboardPresent: Boolean): Boolean =
|
||||
screenWidthDp <= WIDE_SCREEN_MAX_DP && !hardwareKeyboardPresent
|
||||
|
||||
/**
|
||||
* Best-effort hardware-keyboard heuristic from the current [Configuration]
|
||||
* (accepted-less-reliable, plan §"iPad-equivalent" — no `GCKeyboard` equivalent):
|
||||
* an alphabetic keyboard that is currently exposed (lid open / dock attached).
|
||||
*/
|
||||
internal fun isHardwareKeyboardPresent(keyboard: Int, hardKeyboardHidden: Int): Boolean =
|
||||
keyboard == Configuration.KEYBOARD_QWERTY && hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO
|
||||
|
||||
/**
|
||||
* The mobile key-bar overlay. Pinned ABOVE the IME via [WindowInsets.ime] (falling
|
||||
* back to the navigation-bar inset when the keyboard is hidden), horizontally
|
||||
* scrollable so every key stays reachable on a narrow phone. Renders nothing on
|
||||
* wide screens or when a hardware keyboard is present ([shouldShowKeyBar]).
|
||||
*
|
||||
* @param onSend the IME-bypass sink — A21 wires it to `TerminalSessionController.sendInput`.
|
||||
*/
|
||||
@Composable
|
||||
public fun KeyBar(
|
||||
onSend: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val config = LocalConfiguration.current
|
||||
val hardwareKeyboard = isHardwareKeyboardPresent(config.keyboard, config.hardKeyboardHidden)
|
||||
if (!shouldShowKeyBar(config.screenWidthDp, hardwareKeyboard)) return
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars))
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
for (button in KEYBAR_LAYOUT) {
|
||||
KeyBarKey(button = button, onSend = onSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One inert key button. A tap emits bytes directly via [emitKey] — never through a text field. */
|
||||
@Composable
|
||||
private fun KeyBarKey(button: KeyBarButton, onSend: (String) -> Unit) {
|
||||
val container =
|
||||
if (button.isPrimary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
|
||||
val content =
|
||||
if (button.isPrimary) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Surface(
|
||||
onClick = { emitKey(button.key, onSend) },
|
||||
shape = RoundedCornerShape(Radius.sm8),
|
||||
color = container,
|
||||
contentColor = content,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = LayoutTokens.minHitTarget, minHeight = LayoutTokens.minHitTarget)
|
||||
.semantics { contentDescription = button.title },
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
|
||||
) {
|
||||
Text(text = button.label, style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
text = button.caption,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = content.copy(alpha = Opacity.stale),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The DECCKM split router (plan §6.3 source #3): decides whether a hardware key is
|
||||
* handled app-side (mapped through [KeyByteMap]) or LEFT to Termux's `KeyHandler`.
|
||||
*
|
||||
* Pure decision logic (primitive keyCode + modifier booleans, mirroring the Android
|
||||
* `KeyEvent` constants) so it is JVM-unit-testable with no device / Robolectric.
|
||||
* The Android glue lives in [handle].
|
||||
*/
|
||||
public object HardwareKeyRouter {
|
||||
|
||||
/** The ONLY Ctrl-<letter> chords the app claims — exactly the web key-bar's control keys. */
|
||||
private val CTRL_LETTER_KEYS: Map<Int, KeyByteMap.Key> = mapOf(
|
||||
KeyEvent.KEYCODE_C to KeyByteMap.Key.CTRL_C,
|
||||
KeyEvent.KEYCODE_R to KeyByteMap.Key.CTRL_R,
|
||||
KeyEvent.KEYCODE_O to KeyByteMap.Key.CTRL_O,
|
||||
KeyEvent.KEYCODE_L to KeyByteMap.Key.CTRL_L,
|
||||
KeyEvent.KEYCODE_T to KeyByteMap.Key.CTRL_T,
|
||||
KeyEvent.KEYCODE_B to KeyByteMap.Key.CTRL_B,
|
||||
KeyEvent.KEYCODE_D to KeyByteMap.Key.CTRL_D,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a hardware key to a routing decision.
|
||||
*
|
||||
* - **⇧Tab** → app-handled `ESC[Z`; **plain Tab** defers (completion / DECCKM nav).
|
||||
* - **Esc** (unmodified) → app-handled `ESC`.
|
||||
* - **Ctrl+{C,R,O,L,T,B,D}** → app-handled control byte via [KeyByteMap].
|
||||
* - **Everything else** (arrows, Enter, plain Tab, plain letters, unmapped Ctrl
|
||||
* chords) → [HardwareKeyResult.DeferToTerminal] so Termux's `KeyHandler`
|
||||
* emits the DECCKM-correct sequence (`ESC O A` under application-cursor mode).
|
||||
*/
|
||||
public fun resolve(keyCode: Int, ctrl: Boolean, shift: Boolean, alt: Boolean): HardwareKeyResult {
|
||||
// ⇧Tab is app-handled; plain Tab is left to Termux (do this BEFORE the generic defer).
|
||||
if (keyCode == KeyEvent.KEYCODE_TAB) {
|
||||
return if (shift && !ctrl && !alt) app(KeyByteMap.Key.SHIFT_TAB) else HardwareKeyResult.DeferToTerminal
|
||||
}
|
||||
// Esc → interrupt Claude; a modified Escape is not our chord.
|
||||
if (keyCode == KeyEvent.KEYCODE_ESCAPE) {
|
||||
return if (!ctrl && !shift && !alt) app(KeyByteMap.Key.ESC) else HardwareKeyResult.DeferToTerminal
|
||||
}
|
||||
// The mapped Ctrl-<letter> chords only; unmapped Ctrl letters defer to Termux.
|
||||
if (ctrl && !alt) {
|
||||
CTRL_LETTER_KEYS[keyCode]?.let { return app(it) }
|
||||
}
|
||||
// Arrows / Enter / plain Tab / plain keys → Termux KeyHandler (DECCKM-correct, §6.3).
|
||||
return HardwareKeyResult.DeferToTerminal
|
||||
}
|
||||
|
||||
/**
|
||||
* Android glue for `RemoteTerminalView.onKeyCommand`: extract modifiers from
|
||||
* [event], route via [resolve], and on an app-handled DOWN emit the bytes to
|
||||
* [onSend], returning true to CONSUME the event. Returns false otherwise so the
|
||||
* stock Termux path (`KeyHandler`) handles arrows/Enter/Tab. Device-QA'd
|
||||
* (touches `KeyEvent` accessor methods).
|
||||
*/
|
||||
public fun handle(keyCode: Int, event: KeyEvent, onSend: (String) -> Unit): Boolean {
|
||||
if (event.action != KeyEvent.ACTION_DOWN) return false
|
||||
return when (val result = resolve(keyCode, event.isCtrlPressed, event.isShiftPressed, event.isAltPressed)) {
|
||||
is HardwareKeyResult.AppHandled -> {
|
||||
onSend(result.bytes)
|
||||
true
|
||||
}
|
||||
|
||||
HardwareKeyResult.DeferToTerminal -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun app(key: KeyByteMap.Key): HardwareKeyResult =
|
||||
HardwareKeyResult.AppHandled(KeyByteMap.bytes(key))
|
||||
}
|
||||
|
||||
/** The routing decision for one hardware key (the DECCKM split, plan §6.3). */
|
||||
public sealed interface HardwareKeyResult {
|
||||
/** The app consumes the key and sends these exact [bytes] (resolved via [KeyByteMap]). */
|
||||
public data class AppHandled(val bytes: String) : HardwareKeyResult
|
||||
|
||||
/** The key is left to Termux's `KeyHandler` so DECCKM stays correct (arrows/Enter/Tab). */
|
||||
public data object DeferToTerminal : HardwareKeyResult
|
||||
}
|
||||
|
||||
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "KeyBar")
|
||||
@Composable
|
||||
private fun KeyBarPreview() {
|
||||
WebTermTheme {
|
||||
KeyBar(onSend = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.session.GateState
|
||||
import wang.yaojia.webterm.viewmodels.GateDecision
|
||||
import wang.yaojia.webterm.wire.GateKind
|
||||
|
||||
/**
|
||||
* # PlanGateSheet (A22) — the plan-gate three-way approval bottom sheet.
|
||||
*
|
||||
* The Android analogue of the iOS ExitPlanMode gate (public/tabs.ts:334-350): a Material 3
|
||||
* [ModalBottomSheet] with three actions —
|
||||
* - **批准** → approve + review (`Approve(mode="default")`, the default action),
|
||||
* - **批准并自动接受** → approve + auto-accept edits (`Approve(mode="acceptEdits")`),
|
||||
* - **继续计划** → keep planning (wires to `reject`).
|
||||
*
|
||||
* `mode` is the **TOP-LEVEL** `approve.mode` wire key (plan §1/§4.1) — carried by the resolved
|
||||
* [GateState.Affordance.clientMessage] via [GateViewModel.decide], never hand-built here. The
|
||||
* server-supplied [detail][GateState.detail] renders as **inert [Text]** (untrusted, §8).
|
||||
*
|
||||
* ### Epoch capture (stale-guard seam)
|
||||
* Each action hands back this gate's [epoch][GateState.epoch] so the tap is bound to the gate on
|
||||
* screen; [GateViewModel.decide] drops it if the live gate has moved on. Dismissing the sheet
|
||||
* (scrim/back) resolves NOTHING — the gate stays held until an explicit decision.
|
||||
*
|
||||
* Sheet presentation / detents are device-QA; the wired decisions + epoch capture are the contract.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
public fun PlanGateSheet(
|
||||
gate: GateState,
|
||||
onDecide: (GateDecision, Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.lg16, vertical = Spacing.md12),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Text(text = "批准执行计划", style = MaterialTheme.typography.titleMedium)
|
||||
gate.detail?.let { detail ->
|
||||
// INERT: untrusted plan text rendered verbatim, no linkify/markdown (§8).
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(text = "批准") }
|
||||
OutlinedButton(
|
||||
onClick = { onDecide(GateDecision.ACCEPT_EDITS, gate.epoch) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(text = "批准并自动接受") }
|
||||
TextButton(
|
||||
onClick = { onDecide(GateDecision.REJECT, gate.epoch) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(text = "继续计划") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview(name = "PlanGateSheet")
|
||||
@Composable
|
||||
private fun PlanGateSheetPreview() {
|
||||
WebTermTheme {
|
||||
PlanGateSheet(
|
||||
gate = GateState(kind = GateKind.PLAN, detail = "Refactor the auth module into 3 files.", epoch = 5),
|
||||
onDecide = { _, _ -> },
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import wang.yaojia.webterm.nav.LayoutMode
|
||||
import wang.yaojia.webterm.nav.PointerMenuPolicy
|
||||
|
||||
/**
|
||||
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
|
||||
*
|
||||
* The Android analogue of iOS's pointer context menu (open-in-cwd / kill / copy). A reusable wrapper
|
||||
* that installs a `Modifier.pointerInput` detecting a **secondary** pointer button (mouse right-click,
|
||||
* trackpad two-finger / secondary click) and anchors a Material `DropdownMenu` at the click position.
|
||||
*
|
||||
* ### Gating (plan §5 A26, §"iPad-equivalent", R13, §8)
|
||||
* The whole affordance is gated by the pure [PointerMenuPolicy.enabled] predicate — enabled **only** in
|
||||
* [LayoutMode.LIST_DETAIL] on a genuine tablet (`smallestScreenWidthDp >= 600`). When disabled the
|
||||
* wrapper is a transparent pass-through (no pointer interception, no menu) so phones behave exactly as
|
||||
* before. This is `Modifier.pointerInput` + an anchored `DropdownMenu` — deliberately NOT Compose
|
||||
* Desktop's `ContextMenuArea`, which is not stable on Android (platform review).
|
||||
*
|
||||
* The pointer detection / menu placement / gesture behaviour is device-QA (plan §7); the gate is the
|
||||
* JVM-tested [PointerMenuPolicy] core.
|
||||
*
|
||||
* @param mode the current [LayoutMode] from [wang.yaojia.webterm.nav.LayoutPolicy].
|
||||
* @param actions the menu entries (e.g. 在当前目录开新会话 / 终止 / 复制), each a label + callback.
|
||||
* @param smallestScreenWidthDp the device's smallest-width; defaults to the current configuration.
|
||||
* @param content the wrapped content the secondary-click targets.
|
||||
*/
|
||||
@Composable
|
||||
public fun PointerContextMenu(
|
||||
mode: LayoutMode,
|
||||
actions: List<ContextMenuAction>,
|
||||
modifier: Modifier = Modifier,
|
||||
smallestScreenWidthDp: Int = LocalConfiguration.current.smallestScreenWidthDp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// Gate: on a phone / compact layout the menu never exists and the pointer is never intercepted.
|
||||
if (!PointerMenuPolicy.enabled(mode, smallestScreenWidthDp)) {
|
||||
Box(modifier = modifier) { content() }
|
||||
return
|
||||
}
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var anchor by remember { mutableStateOf(DpOffset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(actions) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.buttons.isSecondaryPressed) {
|
||||
val position = event.changes.first().position
|
||||
anchor = with(density) { DpOffset(position.x.toDp(), position.y.toDp()) }
|
||||
expanded = true
|
||||
// Consume so the underlying content doesn't also react to the secondary press.
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
content()
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
offset = anchor,
|
||||
) {
|
||||
for (action in actions) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(action.label) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
action.onClick()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */
|
||||
public data class ContextMenuAction(val label: String, val onClick: () -> Unit)
|
||||
@@ -0,0 +1,111 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.SuggestionChipDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.Radius
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
|
||||
/**
|
||||
* # QuickReply (A25) — the floating quick-reply chip row.
|
||||
*
|
||||
* A horizontal strip of the editable palette ([QuickReplyStore]). It **floats only
|
||||
* while a gate is held** (plan §1 quick-reply row) — the moment Claude is waiting on
|
||||
* the user is exactly when a one-tap canned reply is useful — and each chip, on tap,
|
||||
* sends its [text][QuickReplyChip.text] VERBATIM via the injected [onSend].
|
||||
*
|
||||
* ### The load-bearing seam (JVM-tested; layout/float animation are device-QA §7)
|
||||
* - **Visibility:** [shouldShowQuickReply] gates the row on `gateHeld && hasChips`.
|
||||
* - **Send:** a tap calls [sendQuickReply] → `onSend(chip.text)` with NO added byte.
|
||||
* A21 wires `onSend` to `TerminalSessionController.sendInput` (invariant #1 —
|
||||
* input passed through verbatim), and `gateHeld` to the [GateViewModel]
|
||||
* [wang.yaojia.webterm.viewmodels.GateViewModel] held-gate state.
|
||||
*
|
||||
* The chip label is the user's OWN palette text (not untrusted server data), rendered
|
||||
* as a single-line, ellipsised [Text] — no Markdown / autolink.
|
||||
*
|
||||
* @param chips the palette to show, in display order.
|
||||
* @param gateHeld whether a gate is currently held (drives the float).
|
||||
* @param onSend receives the tapped chip's exact text.
|
||||
*/
|
||||
@Composable
|
||||
public fun QuickReply(
|
||||
chips: List<QuickReplyChip>,
|
||||
gateHeld: Boolean,
|
||||
onSend: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (!shouldShowQuickReply(gateHeld = gateHeld, hasChips = chips.isNotEmpty())) return
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(Radius.pill),
|
||||
tonalElevation = Spacing.xs2,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
chips.forEach { chip ->
|
||||
SuggestionChip(
|
||||
onClick = { sendQuickReply(chip, onSend) },
|
||||
label = {
|
||||
Text(
|
||||
text = chip.text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(Radius.pill),
|
||||
colors = SuggestionChipDefaults.suggestionChipColors(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tap→emit seam (mirrors `KeyBar.emitKey`): send the chip's [text][QuickReplyChip.text]
|
||||
* VERBATIM. Kept as a top-level function so the exact-byte contract is JVM-testable
|
||||
* without driving Compose.
|
||||
*/
|
||||
internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) {
|
||||
onSend(chip.text)
|
||||
}
|
||||
|
||||
/** The row floats only while a gate is held AND the palette is non-empty. */
|
||||
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
|
||||
gateHeld && hasChips
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "QuickReply (gate held)")
|
||||
@Composable
|
||||
private fun QuickReplyPreview() {
|
||||
WebTermTheme {
|
||||
QuickReply(
|
||||
chips = QuickReplyDefaults.chips,
|
||||
gateHeld = true,
|
||||
onSend = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.put
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* # QuickReplyChip (A25) — one entry of the editable quick-reply palette.
|
||||
*
|
||||
* A canned snippet the user can tap to send while a gate is held. [id] is a stable
|
||||
* identity used for edit/remove/reorder (survives a [text] change); [text] is the
|
||||
* exact string sent VERBATIM as input via `TerminalSessionController.sendInput`
|
||||
* (plan §1 quick-reply row / §5 A25). No trailing byte is added — the palette owner
|
||||
* types whatever they want submitted.
|
||||
*/
|
||||
public data class QuickReplyChip(
|
||||
val id: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Sensible starter palette, used the first time the store is read (no stored blob
|
||||
* yet). Ids are stable so a default chip can still be edited/removed/reordered by id.
|
||||
* Once the user mutates the palette (including deleting every chip → an empty list),
|
||||
* their choice is persisted and these defaults are never re-seeded.
|
||||
*/
|
||||
public object QuickReplyDefaults {
|
||||
public val chips: List<QuickReplyChip> = listOf(
|
||||
QuickReplyChip("qr-continue", "continue"),
|
||||
QuickReplyChip("qr-yes", "yes"),
|
||||
QuickReplyChip("qr-no", "no"),
|
||||
QuickReplyChip("qr-1", "1"),
|
||||
QuickReplyChip("qr-2", "2"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen CRUD contract for the editable quick-reply palette (plan §5 A25). Every
|
||||
* mutation returns the NEW list (immutable style, mirrors [HostStore]
|
||||
* [wang.yaojia.webterm.hostregistry.HostStore]); nothing is mutated in place.
|
||||
* Cross-device sync is explicitly OUT of scope — each device's store is independent
|
||||
* (plan §1 "Cross-device palette sync" DEFERRED).
|
||||
*
|
||||
* Implementations: [DataStoreQuickReplyStore] (real, Preferences-DataStore-backed)
|
||||
* and [InMemoryQuickReplyStore] (in-`main` double for JVM tests / previews).
|
||||
*/
|
||||
public interface QuickReplyStore {
|
||||
/** The palette in display order (defaults on first read; the user's list thereafter). */
|
||||
public suspend fun loadAll(): List<QuickReplyChip>
|
||||
|
||||
/**
|
||||
* Append a new chip carrying [text] (a fresh id is minted). Blank [text] is an
|
||||
* explicit no-op (returns the list unchanged) — a boundary guard so the palette
|
||||
* never accumulates empty chips. Non-blank [text] is stored VERBATIM (leading /
|
||||
* trailing whitespace preserved). Returns the new list.
|
||||
*/
|
||||
public suspend fun add(text: String): List<QuickReplyChip>
|
||||
|
||||
/**
|
||||
* Replace the [text] of the chip with [id] (position preserved). Unknown [id] or
|
||||
* blank [text] → no-op (unchanged list). Returns the new list.
|
||||
*/
|
||||
public suspend fun edit(id: String, text: String): List<QuickReplyChip>
|
||||
|
||||
/** Remove the chip with [id]. Unknown [id] is a no-op. Returns the new list. */
|
||||
public suspend fun remove(id: String): List<QuickReplyChip>
|
||||
|
||||
/**
|
||||
* Move the chip at [fromIndex] to [toIndex] (drag-reorder). Out-of-range indices
|
||||
* are a no-op (returns the unchanged list). Returns the new list.
|
||||
*/
|
||||
public suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip>
|
||||
}
|
||||
|
||||
// ── Pure collection transforms shared by all QuickReplyStore implementations (DRY) ──
|
||||
// Never mutate the receiver — always return a fresh list. `internal` so both stores
|
||||
// and the same-module unit tests use them without widening the public surface.
|
||||
|
||||
/** Append a chip with [id]/[text], unless [text] is blank (boundary guard → no-op). */
|
||||
internal fun List<QuickReplyChip>.adding(text: String, id: String): List<QuickReplyChip> =
|
||||
if (text.isBlank()) this else this + QuickReplyChip(id, text)
|
||||
|
||||
/** Replace the [text] of the chip with [id] (position kept). Blank text / unknown id → no-op. */
|
||||
internal fun List<QuickReplyChip>.editing(id: String, text: String): List<QuickReplyChip> =
|
||||
if (text.isBlank()) this else map { if (it.id == id) it.copy(text = text) else it }
|
||||
|
||||
/** A copy without the chip whose id equals [id] (unknown id → an unchanged copy). */
|
||||
internal fun List<QuickReplyChip>.removingId(id: String): List<QuickReplyChip> =
|
||||
filter { it.id != id }
|
||||
|
||||
/** Immutable move [from] → [to]; out-of-range (either end) is a no-op. */
|
||||
internal fun List<QuickReplyChip>.moving(from: Int, to: Int): List<QuickReplyChip> {
|
||||
if (from !in indices || to !in indices || from == to) return this
|
||||
val next = toMutableList()
|
||||
next.add(to, next.removeAt(from))
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure JSON codec for the persisted palette (JVM-testable, no Context). A chip list
|
||||
* ⇆ a JSON array of `{"id","text"}` objects. Encode is total; decode is defensive —
|
||||
* the blob is untrusted at rest, so a corrupt array or a chip missing a string
|
||||
* id/text is dropped rather than crashing. Uses the kotlinx JSON element API (no
|
||||
* `@Serializable` codegen → no serialization compiler plugin needed on :app; the
|
||||
* runtime lib is on the classpath via `:wire-protocol`'s `api` dependency).
|
||||
*/
|
||||
internal object QuickReplyCodec {
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
|
||||
fun encode(chips: List<QuickReplyChip>): String =
|
||||
buildJsonArray {
|
||||
chips.forEach { chip ->
|
||||
addJsonObject {
|
||||
put("id", chip.id)
|
||||
put("text", chip.text)
|
||||
}
|
||||
}
|
||||
}.toString()
|
||||
|
||||
fun decode(raw: String?): List<QuickReplyChip> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
val array = try {
|
||||
json.parseToJsonElement(raw) as? JsonArray ?: return emptyList()
|
||||
} catch (_: Exception) {
|
||||
return emptyList() // corrupt blob at rest → start clean, never crash
|
||||
}
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = obj["id"].asStringOrNull()
|
||||
val text = obj["text"].asStringOrNull()
|
||||
if (id.isNullOrEmpty() || text.isNullOrEmpty()) null else QuickReplyChip(id, text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? =
|
||||
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferences-DataStore-backed [QuickReplyStore]. The whole palette is ONE JSON
|
||||
* string under [PALETTE_KEY], serialized by [QuickReplyCodec]; writes are an atomic
|
||||
* read-modify-write via [DataStore.edit], reusing the shared immutable transforms.
|
||||
*
|
||||
* Key-absent (first run) reads/seeds [QuickReplyDefaults]; once any write lands, the
|
||||
* stored list wins — including an empty list, so "delete all" is respected. The
|
||||
* [DataStore] is injected (constructed from a Context in :app DI), so this class has
|
||||
* no Android-framework surface of its own and is exercised in JVM tests via an
|
||||
* in-memory `DataStore<Preferences>` double.
|
||||
*/
|
||||
public class DataStoreQuickReplyStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val newId: () -> String = { UUID.randomUUID().toString() },
|
||||
) : QuickReplyStore {
|
||||
|
||||
override suspend fun loadAll(): List<QuickReplyChip> {
|
||||
val raw = dataStore.data.first()[PALETTE_KEY]
|
||||
return if (raw == null) QuickReplyDefaults.chips else QuickReplyCodec.decode(raw)
|
||||
}
|
||||
|
||||
override suspend fun add(text: String): List<QuickReplyChip> =
|
||||
writeTransform { it.adding(text, newId()) }
|
||||
|
||||
override suspend fun edit(id: String, text: String): List<QuickReplyChip> =
|
||||
writeTransform { it.editing(id, text) }
|
||||
|
||||
override suspend fun remove(id: String): List<QuickReplyChip> =
|
||||
writeTransform { it.removingId(id) }
|
||||
|
||||
override suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip> =
|
||||
writeTransform { it.moving(fromIndex, toIndex) }
|
||||
|
||||
private suspend fun writeTransform(
|
||||
transform: (List<QuickReplyChip>) -> List<QuickReplyChip>,
|
||||
): List<QuickReplyChip> {
|
||||
lateinit var updated: List<QuickReplyChip>
|
||||
dataStore.edit { prefs ->
|
||||
val raw = prefs[PALETTE_KEY]
|
||||
val current = if (raw == null) QuickReplyDefaults.chips else QuickReplyCodec.decode(raw)
|
||||
updated = transform(current)
|
||||
prefs[PALETTE_KEY] = QuickReplyCodec.encode(updated)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PALETTE_KEY = stringPreferencesKey("quickReplyPalette")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory [QuickReplyStore] — lives in `main` (not `test`) so it doubles for the
|
||||
* DataStore store in JVM CRUD tests AND backs Compose previews. Seeded with
|
||||
* [QuickReplyDefaults] by default. A [Mutex] serializes the read-modify-write so
|
||||
* concurrent callers can't interleave; state is replaced wholesale on every change.
|
||||
*/
|
||||
public class InMemoryQuickReplyStore(
|
||||
initial: List<QuickReplyChip> = QuickReplyDefaults.chips,
|
||||
private val newId: () -> String = { UUID.randomUUID().toString() },
|
||||
) : QuickReplyStore {
|
||||
private val mutex = Mutex()
|
||||
private var chips: List<QuickReplyChip> = initial.toList()
|
||||
|
||||
override suspend fun loadAll(): List<QuickReplyChip> = mutex.withLock { chips }
|
||||
|
||||
override suspend fun add(text: String): List<QuickReplyChip> =
|
||||
write { it.adding(text, newId()) }
|
||||
|
||||
override suspend fun edit(id: String, text: String): List<QuickReplyChip> =
|
||||
write { it.editing(id, text) }
|
||||
|
||||
override suspend fun remove(id: String): List<QuickReplyChip> =
|
||||
write { it.removingId(id) }
|
||||
|
||||
override suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip> =
|
||||
write { it.moving(fromIndex, toIndex) }
|
||||
|
||||
private suspend fun write(
|
||||
transform: (List<QuickReplyChip>) -> List<QuickReplyChip>,
|
||||
): List<QuickReplyChip> = mutex.withLock {
|
||||
val updated = transform(chips)
|
||||
chips = updated
|
||||
updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.session.Connection
|
||||
import wang.yaojia.webterm.session.ConnectionState
|
||||
import wang.yaojia.webterm.session.Exited
|
||||
import wang.yaojia.webterm.session.FailureReason
|
||||
import wang.yaojia.webterm.session.SessionEvent
|
||||
import wang.yaojia.webterm.wire.WireConstants
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* # ReconnectBanner (A21) — the connection-status / EXIT banner row.
|
||||
*
|
||||
* The Android port of iOS `ReconnectBanner` + `TerminalViewModel.bannerModel` (plan §1, §5 A21). A
|
||||
* single strip pinned above the terminal that reflects the engine's connection lifecycle:
|
||||
* - `connecting` — first dial (spinner);
|
||||
* - `reconnecting(attempt, countdown)` — the back-off ladder (spinner);
|
||||
* - `failed(replayTooLarge)` — a NON-retryable wall: actionable copy, **NO spinner**, "新会话" action;
|
||||
* - `exited(code, reason)` — the shell is gone: `code == -1` is a spawn-failure (reason required),
|
||||
* otherwise a normal exit; both offer "开新会话".
|
||||
*
|
||||
* ### Precedence (the load-bearing rule — plan §1)
|
||||
* **Terminal phases (`failed` / `exited`) OUTRANK the transient ones (`connecting` / `reconnecting`).**
|
||||
* Once the session is terminally failed or exited, a stray transient connection event must never
|
||||
* overwrite the banner — the reducer [bannerModel] enforces this. This mirrors the iOS `bannerModel`
|
||||
* computed property where the exit/failure state is checked before the reconnect state.
|
||||
*
|
||||
* ### Inert rendering (plan §8)
|
||||
* The exit `reason` is an untrusted server string — it is rendered as a plain, inert Compose [Text]
|
||||
* with no Markdown / autolink / `AnnotatedString` linkify.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What the banner shows, derived purely from the [SessionEvent] stream by [bannerModel]. A sealed model
|
||||
* so the precedence rule and the "no-spinner on failure" rule are data, unit-testable without a device.
|
||||
*/
|
||||
public sealed interface BannerModel {
|
||||
/** Whether a progress spinner is shown — true ONLY for the transient (retrying) phases. */
|
||||
public val showsSpinner: Boolean
|
||||
|
||||
/** Whether the banner offers a "new session in cwd" action (`attach(null, cwd)`). */
|
||||
public val offersNewSession: Boolean
|
||||
|
||||
/** Connected / idle — nothing to show. */
|
||||
public data object Hidden : BannerModel {
|
||||
override val showsSpinner: Boolean get() = false
|
||||
override val offersNewSession: Boolean get() = false
|
||||
}
|
||||
|
||||
/** The first connection is being established. */
|
||||
public data object Connecting : BannerModel {
|
||||
override val showsSpinner: Boolean get() = true
|
||||
override val offersNewSession: Boolean get() = false
|
||||
}
|
||||
|
||||
/** The transport dropped; retry [attempt] fires after [countdown] (the [ConnectionState.Reconnecting] ladder). */
|
||||
public data class Reconnecting(val attempt: Int, val countdown: Duration) : BannerModel {
|
||||
override val showsSpinner: Boolean get() = true
|
||||
override val offersNewSession: Boolean get() = false
|
||||
}
|
||||
|
||||
/**
|
||||
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
|
||||
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
|
||||
*/
|
||||
public data class Failed(val reason: FailureReason) : BannerModel {
|
||||
override val showsSpinner: Boolean get() = false
|
||||
override val offersNewSession: Boolean get() = true
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell exited. [isSpawnFailure] (`code == -1`) means the PTY never spawned — [reason] is then
|
||||
* required and drives the copy. Either way the session is over; offers "开新会话".
|
||||
*/
|
||||
public data class Exited(val code: Int, val reason: String?) : BannerModel {
|
||||
override val showsSpinner: Boolean get() = false
|
||||
override val offersNewSession: Boolean get() = true
|
||||
|
||||
/** `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) → the spawn itself failed. */
|
||||
public val isSpawnFailure: Boolean get() = code == WireConstants.SPAWN_FAILED_EXIT_CODE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE reducer: fold one [SessionEvent] into the [current] [BannerModel]. Only connection-lifecycle and
|
||||
* exit events affect the banner; every other event (`Output`/`Gate`/`Digest`/`Telemetry`/`Adopted`)
|
||||
* leaves it unchanged.
|
||||
*
|
||||
* The precedence rule (plan §1): the terminal phases ([BannerModel.Failed] / [BannerModel.Exited]) are
|
||||
* STICKY over the transient phases — a `connecting`/`reconnecting`/`connected`/`closed` event arriving
|
||||
* after the session already failed or exited does NOT override the terminal banner. A fresh terminal
|
||||
* event always wins (a later exit/failure replaces an earlier one).
|
||||
*/
|
||||
public fun bannerModel(current: BannerModel, event: SessionEvent): BannerModel = when (event) {
|
||||
is Exited -> BannerModel.Exited(event.code, event.reason) // terminal — always wins
|
||||
is Connection -> reduceConnection(current, event.state)
|
||||
else -> current
|
||||
}
|
||||
|
||||
private fun reduceConnection(current: BannerModel, state: ConnectionState): BannerModel = when (state) {
|
||||
is ConnectionState.Failed -> BannerModel.Failed(state.reason) // terminal — always wins
|
||||
ConnectionState.Connecting -> current.orKeepTerminal(BannerModel.Connecting)
|
||||
is ConnectionState.Reconnecting -> current.orKeepTerminal(BannerModel.Reconnecting(state.attempt, state.next))
|
||||
ConnectionState.Connected -> current.orKeepTerminal(BannerModel.Hidden)
|
||||
ConnectionState.Closed -> current.orKeepTerminal(BannerModel.Hidden)
|
||||
}
|
||||
|
||||
/** A terminal banner outranks any transient update; otherwise take the [transient] one. */
|
||||
private fun BannerModel.orKeepTerminal(transient: BannerModel): BannerModel =
|
||||
if (this is BannerModel.Failed || this is BannerModel.Exited) this else transient
|
||||
|
||||
// ── UI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The banner row. Renders NOTHING for [BannerModel.Hidden]. [onNewSession] fires the "new session in
|
||||
* cwd" action (the exit-banner and replay-too-large affordances both route to it).
|
||||
*
|
||||
* Compose layout / spinner animation is device-QA (plan §7); the reduced [model] + its copy/affordance
|
||||
* mapping is the JVM-tested core.
|
||||
*/
|
||||
@Composable
|
||||
public fun ReconnectBanner(
|
||||
model: BannerModel,
|
||||
onNewSession: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (model is BannerModel.Hidden) return
|
||||
|
||||
val container = when (model) {
|
||||
is BannerModel.Failed, is BannerModel.Exited -> MaterialTheme.colorScheme.errorContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val content = when (model) {
|
||||
is BannerModel.Failed, is BannerModel.Exited -> MaterialTheme.colorScheme.onErrorContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
Surface(color = container, contentColor = content, modifier = modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||
) {
|
||||
if (model.showsSpinner) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
// Untrusted server strings (exit reason) render as inert Text — no autolink/markdown (§8).
|
||||
Text(
|
||||
text = bannerCopy(model),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (model.offersNewSession) {
|
||||
TextButton(onClick = onNewSession) { Text(newSessionLabel(model)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The Chinese status copy for [model]. Kept separate from the reducer so copy never leaks into logic. */
|
||||
private fun bannerCopy(model: BannerModel): String = when (model) {
|
||||
BannerModel.Hidden -> ""
|
||||
BannerModel.Connecting -> "连接中…"
|
||||
is BannerModel.Reconnecting ->
|
||||
"连接断开,重连中(第 ${model.attempt} 次,${model.countdown.inWholeSeconds} 秒后重试)"
|
||||
is BannerModel.Failed -> when (model.reason) {
|
||||
FailureReason.REPLAY_TOO_LARGE ->
|
||||
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
|
||||
}
|
||||
is BannerModel.Exited ->
|
||||
if (model.isSpawnFailure) {
|
||||
"会话启动失败:${model.reason ?: "未知原因"}"
|
||||
} else {
|
||||
"会话已结束(退出码 ${model.code})"
|
||||
}
|
||||
}
|
||||
|
||||
/** "开新会话" for a clean exit, "新会话" for the replay-too-large wall — both call [ReconnectBanner]'s onNewSession. */
|
||||
private fun newSessionLabel(model: BannerModel): String =
|
||||
if (model is BannerModel.Failed) "新会话" else "开新会话"
|
||||
|
||||
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "ReconnectBanner — reconnecting")
|
||||
@Composable
|
||||
private fun ReconnectBannerReconnectingPreview() {
|
||||
WebTermTheme {
|
||||
ReconnectBanner(model = BannerModel.Reconnecting(attempt = 2, countdown = 4.seconds), onNewSession = {})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "ReconnectBanner — exited")
|
||||
@Composable
|
||||
private fun ReconnectBannerExitedPreview() {
|
||||
WebTermTheme {
|
||||
ReconnectBanner(model = BannerModel.Exited(code = 0, reason = null), onNewSession = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||
import wang.yaojia.webterm.designsystem.DisplayStatus
|
||||
import wang.yaojia.webterm.designsystem.Opacity
|
||||
import wang.yaojia.webterm.designsystem.Radius
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.StatusBadge
|
||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
import wang.yaojia.webterm.viewmodels.SessionRow
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import wang.yaojia.webterm.wire.StatusTelemetry
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* # SessionListRow (A20) — one dashboard row for a running/just-exited session.
|
||||
*
|
||||
* Renders the parts the plan §1 session-list row enumerates: **preview thumbnail** (left) · **status
|
||||
* badge** + sanitized **title** + **unread dot** (top line) · **cols×rows** geometry + cwd (meta line) ·
|
||||
* **telemetry chips** (bottom line). Every server-influenced string ([SessionRow.title] — already run
|
||||
* through `TitleSanitizer` in the ViewModel — plus cwd and the telemetry chip labels) renders as INERT
|
||||
* [Text]: no linkify / Markdown / `AnnotatedString` autolink (plan §8). Exited rows dim to
|
||||
* [Opacity.exited].
|
||||
*
|
||||
* The whole row is tap-to-open ([onOpen]); swipe-to-kill is owned by the enclosing `SwipeToDismissBox` in
|
||||
* `SessionListScreen`. Layout/gesture behaviour is device-QA (plan §7); the row's DERIVED display values
|
||||
* are computed by the JVM-tested `sessionRowsOf` in the ViewModel.
|
||||
*
|
||||
* @param thumbnails the off-screen preview seam — production wires it to the active host's
|
||||
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (`(sessionId, lastOutputAt)`-keyed,
|
||||
* §6.7); `null` renders the placeholder tile (the JVM/preview path draws no bitmap).
|
||||
*/
|
||||
@Composable
|
||||
public fun SessionListRow(
|
||||
row: SessionRow,
|
||||
onOpen: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
thumbnails: SessionThumbnails? = null,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(if (row.displayStatus == DisplayStatus.Exited) Opacity.exited else 1f)
|
||||
.clip(RoundedCornerShape(Radius.md12))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onOpen)
|
||||
.padding(Spacing.md12),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ThumbnailTile(session = row.info, thumbnails = thumbnails)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
TitleLine(row = row)
|
||||
Text(
|
||||
text = metaLine(row.info),
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.info.telemetry?.let { telemetry ->
|
||||
TelemetryChips(telemetry = telemetry, nowMs = nowMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Top line: status badge · unread dot · sanitized title (or an id/cwd fallback when the title is empty). */
|
||||
@Composable
|
||||
private fun TitleLine(row: SessionRow) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
StatusBadge(status = row.displayStatus)
|
||||
if (row.isUnread) UnreadDot()
|
||||
Text(
|
||||
text = row.title.ifBlank { fallbackLabel(row.info) },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The unread dot (accent-filled), shown when server output is newer than the local seen-watermark (§1). */
|
||||
@Composable
|
||||
private fun UnreadDot() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(Spacing.sm8)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview thumbnail tile. Loads the bitmap once per `(id, lastOutputAt)` from the [thumbnails] seam
|
||||
* (so an unchanged screen is served from the pipeline's cache) and draws it; while loading (or when no
|
||||
* seam is wired) it shows a terminal-colored placeholder so the row height never jumps.
|
||||
*/
|
||||
@Composable
|
||||
private fun ThumbnailTile(session: LiveSessionInfo, thumbnails: SessionThumbnails?) {
|
||||
var bitmap by remember(session.id, session.lastOutputAt) { mutableStateOf<Bitmap?>(null) }
|
||||
LaunchedEffect(session.id, session.lastOutputAt, thumbnails) {
|
||||
bitmap = thumbnails?.bitmapFor(session)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(THUMBNAIL_WIDTH)
|
||||
.height(THUMBNAIL_HEIGHT)
|
||||
.clip(RoundedCornerShape(Radius.sm8))
|
||||
.background(WebTermColors.terminalBackground),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().height(THUMBNAIL_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "161×50 · ~/src/web-terminal" — tabular geometry + cwd (× is U+00D7). Both inert (§8). */
|
||||
private fun metaLine(info: LiveSessionInfo): String {
|
||||
val dims = "${info.cols}×${info.rows}"
|
||||
val cwd = info.cwd?.takeIf { it.isNotBlank() }
|
||||
return if (cwd != null) "$dims · $cwd" else dims
|
||||
}
|
||||
|
||||
/** When a session has no OSC title, label it by cwd (last segment) or the short id — never blank. */
|
||||
private fun fallbackLabel(info: LiveSessionInfo): String {
|
||||
val cwd = info.cwd?.takeIf { it.isNotBlank() }
|
||||
if (cwd != null) return cwd.trimEnd('/').substringAfterLast('/').ifBlank { cwd }
|
||||
return info.id.toString().substringBefore('-')
|
||||
}
|
||||
|
||||
/**
|
||||
* The off-screen thumbnail seam. Production is a thin adapter over the active host's
|
||||
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline]
|
||||
* (`{ session -> pipeline.thumbnail(session) }`); the default `null` seam renders the placeholder tile so
|
||||
* the row composes with no `android.graphics` dependency under preview/JVM.
|
||||
*/
|
||||
public fun interface SessionThumbnails {
|
||||
/** The cached/rendered preview bitmap for [session], or `null` when unavailable (placeholder shown). */
|
||||
public suspend fun bitmapFor(session: LiveSessionInfo): Bitmap?
|
||||
}
|
||||
|
||||
private val THUMBNAIL_WIDTH = Spacing.xxl24 * 4 // 96dp
|
||||
private val THUMBNAIL_HEIGHT = Spacing.xxl24 * 2.5f // 60dp
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "SessionListRow")
|
||||
@Composable
|
||||
private fun SessionListRowPreview() {
|
||||
WebTermTheme {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
|
||||
SessionListRow(
|
||||
row = SessionRow(
|
||||
info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L),
|
||||
displayStatus = DisplayStatus.Working,
|
||||
title = "web-terminal",
|
||||
isUnread = true,
|
||||
),
|
||||
onOpen = {},
|
||||
)
|
||||
SessionListRow(
|
||||
row = SessionRow(
|
||||
info = previewInfo(status = ClaudeStatus.IDLE, title = "", cols = 80, rows = 24),
|
||||
displayStatus = DisplayStatus.Exited,
|
||||
title = "",
|
||||
isUnread = false,
|
||||
),
|
||||
onOpen = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun previewInfo(
|
||||
status: ClaudeStatus,
|
||||
title: String,
|
||||
cols: Int = 161,
|
||||
rows: Int = 50,
|
||||
lastOutputAt: Long? = null,
|
||||
): LiveSessionInfo = LiveSessionInfo(
|
||||
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
|
||||
createdAt = 1L,
|
||||
clientCount = 1,
|
||||
status = status,
|
||||
exited = false,
|
||||
cwd = "/Users/dev/src/web-terminal",
|
||||
title = title.ifBlank { null },
|
||||
cols = cols,
|
||||
rows = rows,
|
||||
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
|
||||
lastOutputAt = lastOutputAt,
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.TelemetryChip
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.wire.PrInfo
|
||||
import wang.yaojia.webterm.wire.RateInfo
|
||||
import wang.yaojia.webterm.wire.StatusTelemetry
|
||||
import wang.yaojia.webterm.wire.Tunables
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* # TelemetryChips (A22) — the statusLine telemetry chip row.
|
||||
*
|
||||
* A horizontally-scrollable row of A13 [TelemetryChip] primitives — context %, $cost, model, PR,
|
||||
* rate — derived from the latest [StatusTelemetry] frame (plan §1). Every chip **greys out together**
|
||||
* when the frame is stale (its [at][StatusTelemetry.at] older than
|
||||
* [TELEMETRY_STALE_TTL_MS][Tunables.TELEMETRY_STALE_TTL_MS]); a chip switches to the amber warning
|
||||
* color when its metric crosses a threshold (near-full context, near-limit rate, PR changes-requested).
|
||||
*
|
||||
* Chip text is inert monospaced [TelemetryChip] output (no linkify, §8). The model string is
|
||||
* server-controlled but renders as a plain chip label. Layout is device-QA; the staleness rule and
|
||||
* chip derivation are the JVM-tested core ([isTelemetryStale] / [telemetryChipModels]).
|
||||
*
|
||||
* @param nowMs current wall clock (ms since epoch); the screen re-supplies it on a tick so chips grey
|
||||
* as the frame ages.
|
||||
*/
|
||||
@Composable
|
||||
public fun TelemetryChips(
|
||||
telemetry: StatusTelemetry,
|
||||
nowMs: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val stale = isTelemetryStale(telemetry.at, nowMs)
|
||||
val models = telemetryChipModels(telemetry)
|
||||
Row(
|
||||
modifier = modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
for (model in models) {
|
||||
TelemetryChip(text = model.text, isStale = stale, isWarning = model.isWarning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One derived chip: pre-formatted [text] plus whether it crossed a warning threshold. */
|
||||
public data class TelemetryChipModel(val text: String, val isWarning: Boolean = false)
|
||||
|
||||
/** Context %, 5-hour and 7-day rate at/above which a chip turns amber (mirrors the web statusline). */
|
||||
private const val CONTEXT_WARN_PCT: Double = 90.0
|
||||
private const val RATE_WARN_PCT: Double = 90.0
|
||||
private const val REVIEW_CHANGES_REQUESTED: String = "CHANGES_REQUESTED"
|
||||
|
||||
/**
|
||||
* True iff [telemetry.at][StatusTelemetry.at] (server receive time) is older than the stale TTL as of
|
||||
* [nowMs]. Boundary: exactly-TTL-old is NOT yet stale ("older than", [Tunables.TELEMETRY_STALE_TTL_MS]).
|
||||
* Pure — the JVM-tested staleness threshold.
|
||||
*/
|
||||
public fun isTelemetryStale(atMs: Long, nowMs: Long): Boolean =
|
||||
nowMs - atMs > Tunables.TELEMETRY_STALE_TTL_MS
|
||||
|
||||
/**
|
||||
* Derive the ordered chip list from a telemetry frame, skipping absent metrics. Pure + deterministic
|
||||
* so the formatting + warning thresholds are unit-tested without Compose. Order mirrors the web
|
||||
* statusLine: context · cost · model · PR · rate.
|
||||
*/
|
||||
public fun telemetryChipModels(telemetry: StatusTelemetry): List<TelemetryChipModel> {
|
||||
val chips = mutableListOf<TelemetryChipModel>()
|
||||
telemetry.contextUsedPct?.let {
|
||||
chips += TelemetryChipModel("ctx ${it.roundToInt()}%", isWarning = it >= CONTEXT_WARN_PCT)
|
||||
}
|
||||
telemetry.costUsd?.let {
|
||||
chips += TelemetryChipModel(String.format(Locale.US, "$%.2f", it))
|
||||
}
|
||||
telemetry.model?.takeIf { it.isNotBlank() }?.let {
|
||||
chips += TelemetryChipModel(it)
|
||||
}
|
||||
telemetry.pr?.let { chips += prChip(it) }
|
||||
telemetry.rate?.fiveHourPct?.let {
|
||||
chips += TelemetryChipModel("5h ${it.roundToInt()}%", isWarning = it >= RATE_WARN_PCT)
|
||||
}
|
||||
return chips
|
||||
}
|
||||
|
||||
private fun prChip(pr: PrInfo): TelemetryChipModel =
|
||||
TelemetryChipModel("PR #${pr.number}", isWarning = pr.reviewState == REVIEW_CHANGES_REQUESTED)
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "TelemetryChips")
|
||||
@Composable
|
||||
private fun TelemetryChipsPreview() {
|
||||
WebTermTheme {
|
||||
TelemetryChips(
|
||||
telemetry = StatusTelemetry(
|
||||
contextUsedPct = 92.0,
|
||||
costUsd = 0.1234,
|
||||
model = "opus-4.8",
|
||||
pr = PrInfo(number = 7, url = "", reviewState = REVIEW_CHANGES_REQUESTED),
|
||||
rate = RateInfo(fiveHourPct = 40.0),
|
||||
at = 0L,
|
||||
),
|
||||
nowMs = 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package wang.yaojia.webterm.designsystem
|
||||
|
||||
/**
|
||||
* # WebTerm design system — the FROZEN numeric spec (pure Kotlin, JVM-testable).
|
||||
*
|
||||
* Mirrors the iOS `DS` token vocabulary
|
||||
* (`ios/App/WebTerm/DesignSystem/Tokens.swift`). Every visual constant lives here
|
||||
* as a raw ARGB [Long] / [Float] / [Int] with **no Compose import**, so it can be
|
||||
* unit-tested on the plain JVM with no device (see `DesignSpecTest`). `Tokens.kt`
|
||||
* builds Compose `Color`/`Dp` values from these; screens must reference the tokens,
|
||||
* never inline a hex/gap.
|
||||
*
|
||||
* Direction: "精致原生" (refined native), dark-appearance-first, amber-gold accent
|
||||
* matched to the desktop/web theme (`public/style.css` `--accent`). Color is NEVER
|
||||
* the only status signal — pair it with the shape/label in `StatusStyle`.
|
||||
*/
|
||||
public object DesignSpec {
|
||||
|
||||
// ── Accent (amber gold — matches desktop --accent / --accent-2) ─────────────
|
||||
// Adaptive: dark = #E3A64A (gold), light = #C9892F (deeper gold) for contrast
|
||||
// on a light background. Gold needs DARK ink on top → use [ON_ACCENT].
|
||||
public const val ACCENT_DARK: Long = 0xFFE3A64AL // #E3A64A (web --accent)
|
||||
public const val ACCENT_LIGHT: Long = 0xFFC9892FL // #C9892F (web --accent-2)
|
||||
public const val ON_ACCENT: Long = 0xFF1A1305L // #1A1305 (web --on-accent)
|
||||
|
||||
// ── Semantic status colors (match the desktop/web status palette) ───────────
|
||||
// These are the ONLY status colors. `StatusStyle` pairs each with a distinct
|
||||
// shape so status is never conveyed by color alone (color-blind safe).
|
||||
public const val STATUS_WORKING: Long = 0xFF46D07FL // #46D07F (web --green)
|
||||
public const val STATUS_WAITING: Long = 0xFFF5B14CL // #F5B14C (web --amber)
|
||||
public const val STATUS_STUCK: Long = 0xFFFF6B6BL // #FF6B6B (web --red)
|
||||
public const val STATUS_IDLE: Long = 0xFF8B8578L // warm secondary gray
|
||||
public const val STATUS_UNKNOWN: Long = 0xFF6E6A61L // neutral gray (no signal yet)
|
||||
|
||||
// ── Timeline event classes (A28) ────────────────────────────────────────────
|
||||
public const val TIMELINE_TOOL: Long = 0xFF5E9EFFL // indigo — a tool run
|
||||
public const val TIMELINE_USER: Long = 0xFFAF7BFFL // violet — a user message
|
||||
|
||||
// ── Surfaces & text — DARK scheme (the default appearance) ──────────────────
|
||||
public const val DARK_BACKGROUND: Long = 0xFF14130FL // warm near-black chrome
|
||||
public const val DARK_SURFACE: Long = 0xFF1C1A15L
|
||||
public const val DARK_CARD: Long = 0xFF24211AL // grouped-content step-up
|
||||
public const val DARK_HAIRLINE: Long = 0xFF35322AL // separator/border
|
||||
public const val DARK_TEXT_PRIMARY: Long = 0xFFECE9E3L // warm off-white (web --text)
|
||||
public const val DARK_TEXT_SECONDARY: Long = 0xFFA8A296L
|
||||
public const val DARK_TEXT_TERTIARY: Long = 0xFF6E6A61L
|
||||
|
||||
// ── Surfaces & text — LIGHT scheme ──────────────────────────────────────────
|
||||
public const val LIGHT_BACKGROUND: Long = 0xFFFAF8F3L // warm off-white
|
||||
public const val LIGHT_SURFACE: Long = 0xFFFFFFFFL
|
||||
public const val LIGHT_CARD: Long = 0xFFF0EDE6L
|
||||
public const val LIGHT_HAIRLINE: Long = 0xFFD8D3C8L
|
||||
public const val LIGHT_TEXT_PRIMARY: Long = 0xFF1A1712L
|
||||
public const val LIGHT_TEXT_SECONDARY: Long = 0xFF6B6559L
|
||||
public const val LIGHT_TEXT_TERTIARY: Long = 0xFF9A9488L
|
||||
|
||||
// ── Terminal canvas (FIXED — independent of app theme) ──────────────────────
|
||||
// A terminal reads as dark regardless of app appearance (like the desktop).
|
||||
// Values mirror the web chrome: --bg #100F0D / --text #ECE9E3, gold caret.
|
||||
public const val TERMINAL_BACKGROUND: Long = 0xFF100F0DL // web --bg
|
||||
public const val TERMINAL_FOREGROUND: Long = 0xFFECE9E3L // web --text
|
||||
public const val TERMINAL_CARET: Long = 0xFFE3A64AL // gold caret
|
||||
|
||||
// ── Spacing scale — 2·4·8·12·16·20·24 (dp). No off-scale gaps. ──────────────
|
||||
public const val SPACE_XS2: Float = 2f
|
||||
public const val SPACE_XS4: Float = 4f
|
||||
public const val SPACE_SM8: Float = 8f
|
||||
public const val SPACE_MD12: Float = 12f
|
||||
public const val SPACE_LG16: Float = 16f
|
||||
public const val SPACE_XL20: Float = 20f
|
||||
public const val SPACE_XXL24: Float = 24f
|
||||
|
||||
// ── Corner radii (dp) ───────────────────────────────────────────────────────
|
||||
public const val RADIUS_SM8: Float = 8f
|
||||
public const val RADIUS_MD12: Float = 12f
|
||||
public const val RADIUS_LG16: Float = 16f
|
||||
public const val RADIUS_PILL: Float = 999f
|
||||
|
||||
// ── Stroke (dp) ──────────────────────────────────────────────────────────────
|
||||
public const val STROKE_HAIRLINE: Float = 1f
|
||||
|
||||
// ── Opacity (dimming multipliers) ────────────────────────────────────────────
|
||||
public const val OPACITY_STALE: Float = 0.45f // telemetry past its TTL
|
||||
public const val OPACITY_EXITED: Float = 0.55f // a session that has exited
|
||||
public const val OPACITY_PRESSED: Float = 0.72f // pressed-state feedback
|
||||
public const val OPACITY_ACCENT_SOFT: Float = 0.15f // faint accent wash
|
||||
|
||||
// ── Layout (dp) ───────────────────────────────────────────────────────────────
|
||||
public const val MIN_HIT_TARGET: Float = 44f // Material/HIG minimum touch target
|
||||
|
||||
// ── Motion (ms) — subtle, eased; ALWAYS gate through reduce-motion ──────────
|
||||
public const val MOTION_FAST_MS: Int = 180
|
||||
public const val MOTION_BASE_MS: Int = 250
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package wang.yaojia.webterm.designsystem
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
|
||||
/**
|
||||
* # Primitives — reusable Compose building blocks (mirrors iOS `Primitives.swift`).
|
||||
*
|
||||
* `StatusBadge`, `TelemetryChip`, `WebTermCard`. Each pulls ALL constants from the
|
||||
* design tokens — no inline magic. Untrusted server strings render as INERT
|
||||
* [Text] (no Markdown / linkify / AnnotatedString autolink), per plan §8.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The visual states a status indicator can show — a superset of the wire
|
||||
* [ClaudeStatus] plus two App-layer emphasis states (mirrors iOS `DisplayStatus`):
|
||||
* `PendingApproval` ("needs me", outranks status) and `Exited` (read-only).
|
||||
*/
|
||||
public enum class DisplayStatus {
|
||||
Working, Waiting, Idle, Stuck, Unknown, PendingApproval, Exited;
|
||||
|
||||
public companion object {
|
||||
/** Bridge from the wire enum (no pending/exited emphasis — callers add). */
|
||||
public fun from(status: ClaudeStatus): DisplayStatus = when (status) {
|
||||
ClaudeStatus.WORKING -> Working
|
||||
ClaudeStatus.WAITING -> Waiting
|
||||
ClaudeStatus.IDLE -> Idle
|
||||
ClaudeStatus.STUCK -> Stuck
|
||||
ClaudeStatus.UNKNOWN -> Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved visuals for one status: a semantic [color], a DISTINCT [symbol] glyph
|
||||
* (so shape alone disambiguates — color-blind safe), and a Chinese [label] (also
|
||||
* the accessibility description). Pure + deterministic (mirrors iOS `StatusStyle`).
|
||||
*/
|
||||
public data class StatusStyle(
|
||||
val color: Color,
|
||||
val symbol: String,
|
||||
val label: String,
|
||||
) {
|
||||
public companion object {
|
||||
public fun of(status: DisplayStatus): StatusStyle = when (status) {
|
||||
DisplayStatus.Working -> StatusStyle(WebTermColors.statusWorking, "●", "运行中")
|
||||
DisplayStatus.Waiting -> StatusStyle(WebTermColors.statusWaiting, "◔", "等待中")
|
||||
DisplayStatus.Idle -> StatusStyle(WebTermColors.statusIdle, "○", "空闲")
|
||||
DisplayStatus.Stuck -> StatusStyle(WebTermColors.statusStuck, "▲", "卡住")
|
||||
DisplayStatus.Unknown -> StatusStyle(WebTermColors.statusUnknown, "?", "未知")
|
||||
DisplayStatus.PendingApproval -> StatusStyle(WebTermColors.statusWaiting, "!", "等待审批")
|
||||
DisplayStatus.Exited -> StatusStyle(WebTermColors.statusIdle, "⚑", "已退出")
|
||||
}
|
||||
|
||||
public fun of(status: ClaudeStatus): StatusStyle = of(DisplayStatus.from(status))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Color + distinct glyph (+ optional Chinese label) for one status. Status is
|
||||
* conveyed by shape, color AND the semantics label — never color alone.
|
||||
*/
|
||||
@Composable
|
||||
public fun StatusBadge(
|
||||
status: DisplayStatus,
|
||||
modifier: Modifier = Modifier,
|
||||
showsLabel: Boolean = false,
|
||||
) {
|
||||
val style = StatusStyle.of(status)
|
||||
Row(
|
||||
modifier = modifier.semantics { contentDescription = style.label },
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = style.symbol, color = style.color, style = MaterialTheme.typography.labelMedium)
|
||||
if (showsLabel) {
|
||||
Text(
|
||||
text = style.label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One pill of monospaced-tabular telemetry (context %, $cost, model, PR…). Greys
|
||||
* out when [isStale]; switches to the waiting/amber color when [isWarning].
|
||||
*/
|
||||
@Composable
|
||||
public fun TelemetryChip(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isStale: Boolean = false,
|
||||
isWarning: Boolean = false,
|
||||
) {
|
||||
val fg = if (isWarning) WebTermColors.statusWaiting else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Text(
|
||||
text = text,
|
||||
style = WebTermType.metaMono,
|
||||
color = fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
.alpha(if (isStale) Opacity.stale else 1f)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.pill))
|
||||
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs2),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard card container: card surface + hairline border + `md12` radius +
|
||||
* standard padding. The uniform card spec for rows, grid cells and panels.
|
||||
*/
|
||||
@Composable
|
||||
public fun WebTermCard(
|
||||
modifier: Modifier = Modifier,
|
||||
padding: androidx.compose.ui.unit.Dp = Spacing.md12,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.md12))
|
||||
.border(Stroke.hairline, MaterialTheme.colorScheme.outline, RoundedCornerShape(Radius.md12))
|
||||
.padding(padding),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Previews ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "StatusBadge")
|
||||
@Composable
|
||||
private fun StatusBadgePreview() {
|
||||
WebTermTheme {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.md12)) {
|
||||
DisplayStatus.entries.forEach { StatusBadge(status = it, showsLabel = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "TelemetryChip")
|
||||
@Composable
|
||||
private fun TelemetryChipPreview() {
|
||||
WebTermTheme {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
TelemetryChip(text = "ctx 92%", isWarning = true)
|
||||
TelemetryChip(text = "$0.1234")
|
||||
TelemetryChip(text = "PR #7", isStale = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Card")
|
||||
@Composable
|
||||
private fun CardPreview() {
|
||||
WebTermTheme {
|
||||
WebTermCard {
|
||||
Text(text = "web-terminal · 161×50", style = WebTermType.metaMono)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package wang.yaojia.webterm.designsystem
|
||||
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Whether the OS "remove animations" accessibility setting is on. Motion tokens
|
||||
* are ALWAYS routed through this (the analogue of iOS `DS.Motion.gated`): when
|
||||
* true, callers collapse animations to instant. Provided by [WebTermTheme].
|
||||
*/
|
||||
public val LocalReduceMotion: androidx.compose.runtime.ProvidableCompositionLocal<Boolean> =
|
||||
staticCompositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* # WebTermTheme — the app's Material 3 theme (mirrors the iOS `DS` appearance).
|
||||
*
|
||||
* Dark-appearance-first: the [darkColorScheme] carries the real design intent;
|
||||
* the [lightColorScheme] is a faithful light counterpart. The amber-gold accent
|
||||
* maps to `primary` (with dark `onPrimary` ink — gold needs dark text). Surface
|
||||
* and text colors come from [WebTermColors]. The terminal canvas colors are NOT
|
||||
* part of the scheme — they are fixed and read directly from [WebTermColors] by
|
||||
* the terminal view, independent of app appearance.
|
||||
*/
|
||||
@Composable
|
||||
public fun WebTermTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
CompositionLocalProvider(LocalReduceMotion provides reduceMotion) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = WebTermType.ramp,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the OS animator-duration-scale; 0 means "remove animations" is on. */
|
||||
@Composable
|
||||
private fun rememberReduceMotion(): Boolean {
|
||||
val context = LocalContext.current
|
||||
return remember(context) {
|
||||
val scale = Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
)
|
||||
scale == 0f
|
||||
}
|
||||
}
|
||||
|
||||
// The scheme-independent status/accent colors stay in WebTermColors; the scheme
|
||||
// here only maps surfaces/text/accent into Material 3's slots.
|
||||
private val DarkColorScheme: ColorScheme = darkColorScheme(
|
||||
primary = WebTermColors.dark.accent,
|
||||
onPrimary = WebTermColors.onAccent,
|
||||
secondary = WebTermColors.dark.accent,
|
||||
onSecondary = WebTermColors.onAccent,
|
||||
background = WebTermColors.dark.background,
|
||||
onBackground = WebTermColors.dark.textPrimary,
|
||||
surface = WebTermColors.dark.surface,
|
||||
onSurface = WebTermColors.dark.textPrimary,
|
||||
surfaceVariant = WebTermColors.dark.card,
|
||||
onSurfaceVariant = WebTermColors.dark.textSecondary,
|
||||
outline = WebTermColors.dark.hairline,
|
||||
error = WebTermColors.statusStuck,
|
||||
)
|
||||
|
||||
private val LightColorScheme: ColorScheme = lightColorScheme(
|
||||
primary = WebTermColors.light.accent,
|
||||
onPrimary = WebTermColors.onAccent,
|
||||
secondary = WebTermColors.light.accent,
|
||||
onSecondary = WebTermColors.onAccent,
|
||||
background = WebTermColors.light.background,
|
||||
onBackground = WebTermColors.light.textPrimary,
|
||||
surface = WebTermColors.light.surface,
|
||||
onSurface = WebTermColors.light.textPrimary,
|
||||
surfaceVariant = WebTermColors.light.card,
|
||||
onSurfaceVariant = WebTermColors.light.textSecondary,
|
||||
outline = WebTermColors.light.hairline,
|
||||
error = WebTermColors.statusStuck,
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
package wang.yaojia.webterm.designsystem
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* # Tokens — the Compose-facing design vocabulary (mirrors iOS `DS`).
|
||||
*
|
||||
* Thin wrappers that lift [DesignSpec]'s raw numbers into Compose types
|
||||
* (`Color`, `Dp`). Screens/components reference these — never an inline hex, gap
|
||||
* or radius. Split into small objects that mirror the iOS `DS.*` groups.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The two adaptive palettes (dark = default) plus the theme-independent tokens
|
||||
* (accent, semantic status, terminal canvas). The scheme-specific surface/text
|
||||
* colors are consumed by [WebTermTheme] to build the Material 3 `ColorScheme`.
|
||||
*/
|
||||
public object WebTermColors {
|
||||
|
||||
// Accent — theme-adaptive; injected once via the color scheme's `primary`.
|
||||
public val accentDark: Color = Color(DesignSpec.ACCENT_DARK)
|
||||
public val accentLight: Color = Color(DesignSpec.ACCENT_LIGHT)
|
||||
public val onAccent: Color = Color(DesignSpec.ON_ACCENT)
|
||||
/** Faint accent wash (selection/soft highlight) — web --accent-soft. */
|
||||
public val accentSoft: Color = Color(DesignSpec.ACCENT_DARK).copy(alpha = DesignSpec.OPACITY_ACCENT_SOFT)
|
||||
|
||||
// Semantic status (theme-independent — same hex reads on desktop/iOS/Android).
|
||||
public val statusWorking: Color = Color(DesignSpec.STATUS_WORKING)
|
||||
public val statusWaiting: Color = Color(DesignSpec.STATUS_WAITING)
|
||||
public val statusStuck: Color = Color(DesignSpec.STATUS_STUCK)
|
||||
public val statusIdle: Color = Color(DesignSpec.STATUS_IDLE)
|
||||
public val statusUnknown: Color = Color(DesignSpec.STATUS_UNKNOWN)
|
||||
|
||||
// Timeline event classes (A28).
|
||||
public val timelineTool: Color = Color(DesignSpec.TIMELINE_TOOL)
|
||||
public val timelineUser: Color = Color(DesignSpec.TIMELINE_USER)
|
||||
|
||||
// Terminal canvas — FIXED, independent of app theme.
|
||||
public val terminalBackground: Color = Color(DesignSpec.TERMINAL_BACKGROUND)
|
||||
public val terminalForeground: Color = Color(DesignSpec.TERMINAL_FOREGROUND)
|
||||
public val terminalCaret: Color = Color(DesignSpec.TERMINAL_CARET)
|
||||
|
||||
/** One appearance's surface + text colors. Consumed by [WebTermTheme]. */
|
||||
@Immutable
|
||||
public data class Scheme(
|
||||
val background: Color,
|
||||
val surface: Color,
|
||||
val card: Color,
|
||||
val hairline: Color,
|
||||
val textPrimary: Color,
|
||||
val textSecondary: Color,
|
||||
val textTertiary: Color,
|
||||
val accent: Color,
|
||||
)
|
||||
|
||||
public val dark: Scheme = Scheme(
|
||||
background = Color(DesignSpec.DARK_BACKGROUND),
|
||||
surface = Color(DesignSpec.DARK_SURFACE),
|
||||
card = Color(DesignSpec.DARK_CARD),
|
||||
hairline = Color(DesignSpec.DARK_HAIRLINE),
|
||||
textPrimary = Color(DesignSpec.DARK_TEXT_PRIMARY),
|
||||
textSecondary = Color(DesignSpec.DARK_TEXT_SECONDARY),
|
||||
textTertiary = Color(DesignSpec.DARK_TEXT_TERTIARY),
|
||||
accent = accentDark,
|
||||
)
|
||||
|
||||
public val light: Scheme = Scheme(
|
||||
background = Color(DesignSpec.LIGHT_BACKGROUND),
|
||||
surface = Color(DesignSpec.LIGHT_SURFACE),
|
||||
card = Color(DesignSpec.LIGHT_CARD),
|
||||
hairline = Color(DesignSpec.LIGHT_HAIRLINE),
|
||||
textPrimary = Color(DesignSpec.LIGHT_TEXT_PRIMARY),
|
||||
textSecondary = Color(DesignSpec.LIGHT_TEXT_SECONDARY),
|
||||
textTertiary = Color(DesignSpec.LIGHT_TEXT_TERTIARY),
|
||||
accent = accentLight,
|
||||
)
|
||||
}
|
||||
|
||||
/** Spacing scale — 2·4·8·12·16·20·24 (mirrors `DS.Space`). */
|
||||
public object Spacing {
|
||||
public val xs2: Dp = DesignSpec.SPACE_XS2.dp
|
||||
public val xs4: Dp = DesignSpec.SPACE_XS4.dp
|
||||
public val sm8: Dp = DesignSpec.SPACE_SM8.dp
|
||||
public val md12: Dp = DesignSpec.SPACE_MD12.dp
|
||||
public val lg16: Dp = DesignSpec.SPACE_LG16.dp
|
||||
public val xl20: Dp = DesignSpec.SPACE_XL20.dp
|
||||
public val xxl24: Dp = DesignSpec.SPACE_XXL24.dp
|
||||
}
|
||||
|
||||
/** Corner radii (mirrors `DS.Radius`). */
|
||||
public object Radius {
|
||||
public val sm8: Dp = DesignSpec.RADIUS_SM8.dp
|
||||
public val md12: Dp = DesignSpec.RADIUS_MD12.dp
|
||||
public val lg16: Dp = DesignSpec.RADIUS_LG16.dp
|
||||
public val pill: Dp = DesignSpec.RADIUS_PILL.dp
|
||||
}
|
||||
|
||||
/** Border widths (mirrors `DS.Stroke`). */
|
||||
public object Stroke {
|
||||
public val hairline: Dp = DesignSpec.STROKE_HAIRLINE.dp
|
||||
}
|
||||
|
||||
/** Dimming multipliers (mirrors `DS.Opacity`). */
|
||||
public object Opacity {
|
||||
public const val stale: Float = DesignSpec.OPACITY_STALE
|
||||
public const val exited: Float = DesignSpec.OPACITY_EXITED
|
||||
public const val pressed: Float = DesignSpec.OPACITY_PRESSED
|
||||
}
|
||||
|
||||
/** Non-spacing layout constants (mirrors `DS.Layout`). */
|
||||
public object LayoutTokens {
|
||||
public val minHitTarget: Dp = DesignSpec.MIN_HIT_TARGET.dp
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package wang.yaojia.webterm.designsystem
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* # Typography — the type ramp (mirrors iOS `DS.Typography`).
|
||||
*
|
||||
* The proportional ramp is Material 3's default [Typography] (scales with the
|
||||
* system font-size setting, the a11y "keep it scalable" point). Numbers /
|
||||
* dimensions / cost / `cols×rows` / timestamps use [monoTabular]: a monospace
|
||||
* family with **tabular figures** (`tnum`) so columns line up and digits don't
|
||||
* jitter as values change — the analogue of iOS `DS.Typography.mono(_:)`.
|
||||
*/
|
||||
public object WebTermType {
|
||||
|
||||
/** Material 3 default ramp — proportional, system-scalable. */
|
||||
public val ramp: Typography = Typography()
|
||||
|
||||
/**
|
||||
* Monospace + tabular-figures style at the given [size]. Use for anything
|
||||
* numeric that must align or not jump: `cols×rows`, device/client counts,
|
||||
* `$cost`, context %, relative timestamps.
|
||||
*/
|
||||
public fun monoTabular(size: Int = 13): TextStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFeatureSettings = TABULAR_FIGURES,
|
||||
fontSize = size.sp,
|
||||
)
|
||||
|
||||
/** The canonical meta-number style: caption-sized mono + tabular. */
|
||||
public val metaMono: TextStyle = monoTabular(size = 12)
|
||||
|
||||
/** The terminal-line style: mono at body size (the emulator overrides glyphs). */
|
||||
public val terminal: TextStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFeatureSettings = TABULAR_FIGURES,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
|
||||
/** OpenType feature string enabling tabular (fixed-advance) numerals. */
|
||||
private const val TABULAR_FIGURES: String = "tnum"
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package wang.yaojia.webterm.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.OkHttpClient
|
||||
import wang.yaojia.webterm.transport.ClientIdentity
|
||||
import wang.yaojia.webterm.transport.ClientIdentityProvider
|
||||
import wang.yaojia.webterm.transport.OkHttpClientFactory
|
||||
import wang.yaojia.webterm.transport.OkHttpHttpTransport
|
||||
import wang.yaojia.webterm.transport.OkHttpTermTransport
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Network boundary (A15): the ONE shared [OkHttpClient] both transports use, the mTLS bridge, and the
|
||||
* WS + REST transports. One client → the `SSLSocketFactory` + `cache(null)` posture apply uniformly to
|
||||
* WS and REST, and the connection pool is shared (plan §2/§8).
|
||||
*
|
||||
* ### mTLS bridge (A11 [IdentityRepository] → A7 [ClientIdentityProvider])
|
||||
* `:transport-okhttp` never depends on `:client-tls-android`; this module is the bridge. The provider
|
||||
* reads the repository's stable `(SSLSocketFactory, X509TrustManager)` pair; the factory is backed by a
|
||||
* re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no
|
||||
* client rebuild (R4). The provider is read ONCE, when the client is built.
|
||||
*
|
||||
* ### Breaking the construction cycle (A11's frozen constructor)
|
||||
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
|
||||
* client needs the repository's SSL material — a cycle. It is broken with an explicit shared
|
||||
* [ConnectionPool]: the repository is given a lightweight evict-only client that SHARES that pool
|
||||
* ([TlsModule]), and the shared client + its WS variant both use the same pool, so `evictAll()` drops
|
||||
* the real pooled/resumed connections. The graph is then acyclic (client → provider → repo → pool).
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
public object NetworkModule {
|
||||
|
||||
/** The one connection pool shared by the transports AND the repository's evict-only client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideConnectionPool(): ConnectionPool = ConnectionPool()
|
||||
|
||||
/** Bridge the mTLS device identity onto the shared client (read once, at client build). */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideClientIdentityProvider(
|
||||
identityRepository: IdentityRepository,
|
||||
): ClientIdentityProvider = ClientIdentityProvider {
|
||||
val material = identityRepository.sslMaterial()
|
||||
ClientIdentity(material.sslSocketFactory, material.trustManager)
|
||||
}
|
||||
|
||||
/** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideOkHttpClient(
|
||||
identityProvider: ClientIdentityProvider,
|
||||
connectionPool: ConnectionPool,
|
||||
): OkHttpClient =
|
||||
OkHttpClientFactory.create(identityProvider)
|
||||
.newBuilder()
|
||||
.connectionPool(connectionPool)
|
||||
.build()
|
||||
|
||||
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client)
|
||||
|
||||
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideHttpTransport(client: OkHttpClient): HttpTransport = OkHttpHttpTransport(client)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package wang.yaojia.webterm.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import wang.yaojia.webterm.push.PushRegistrarTokenSink
|
||||
import wang.yaojia.webterm.push.PushTokenSink
|
||||
|
||||
/**
|
||||
* Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with
|
||||
* `@BindsOptionalOf` (in `push/PushTokenSink.kt`). With a concrete `@Binds` present, the
|
||||
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
|
||||
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
|
||||
* (self-heal). This is the intended optional-collaborator handoff (no mutable global).
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
public interface PushModule {
|
||||
@Binds
|
||||
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package wang.yaojia.webterm.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.wiring.ColdStartPolicy
|
||||
import wang.yaojia.webterm.wiring.DefaultColdStartPolicy
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Session-wiring boundary (A15): the cold-start route seam.
|
||||
*
|
||||
* The per-host / per-session builders (`ApiClientFactory`, `SessionEngineFactory`) and the composition
|
||||
* root (`AppEnvironment`) are constructor-injected (`@Inject`), so Hilt provides them without an entry
|
||||
* here — this module only wires the [ColdStartPolicy] interface binding.
|
||||
*
|
||||
* NOTE (FIX 1): there is deliberately NO `EventBus` provider. The engine→UI fan-out is PER-SESSION —
|
||||
* a fresh `EventBus` is minted inside `RetainedSessionHolder.bind()` alongside each engine's confined
|
||||
* scope, so two sessions never share one bus (an app-wide singleton would cross-talk `Output`/`Gate`
|
||||
* between sessions since `SessionEvent` carries no session id).
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
public object SessionModule {
|
||||
|
||||
/** Bind the cold-start seam A29 consumes to the host-presence policy. */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideColdStartPolicy(hostStore: HostStore): ColdStartPolicy =
|
||||
DefaultColdStartPolicy(hostStore)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package wang.yaojia.webterm.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import wang.yaojia.webterm.hostregistry.DataStoreHostStore
|
||||
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore.
|
||||
* The host list ([HostStore], key `"hosts"`) and per-host last-session id ([LastSessionStore], key
|
||||
* `"lastSessionId.<hostId>"`) use disjoint keys, so they safely share one DataStore file. Secret
|
||||
* material (the device cert) never lives here — that is the Tink store in [TlsModule].
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
public object StorageModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideDataStore(
|
||||
@ApplicationContext context: Context,
|
||||
): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile(DATASTORE_NAME) }
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideHostStore(dataStore: DataStore<Preferences>): HostStore =
|
||||
DataStoreHostStore(dataStore)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
|
||||
DataStoreLastSessionStore(dataStore)
|
||||
|
||||
private const val DATASTORE_NAME: String = "webterm"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package wang.yaojia.webterm.di
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.OkHttpClient
|
||||
import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository
|
||||
import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter
|
||||
import wang.yaojia.webterm.tlsandroid.CertStore
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.tlsandroid.TinkCertStore
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* mTLS device-identity boundary (A15 → A11): the AndroidKeyStore importer (the ONE non-exportable key
|
||||
* home), the Tink-AEAD-encrypted cert-chain store, and the [IdentityRepository] that ties them to the
|
||||
* shared client for no-relaunch rotation (`connectionPool.evictAll()`).
|
||||
*
|
||||
* The repository's constructor is I/O-free (SSL material is built from standard JCA; keystore/Tink I/O
|
||||
* is lazy). Per A11's KDoc, the FIRST identity touch (summary / mutation / first handshake) does
|
||||
* synchronous AndroidKeyStore + Tink work and MUST be triggered off `Dispatchers.Main` — that warm-up
|
||||
* is A21/A27's concern, not this wiring's.
|
||||
*
|
||||
* The repository is given an evict-only client that SHARES [NetworkModule]'s [ConnectionPool] (see the
|
||||
* cycle-break note there), so `evictAll()` drops the real transports' pooled/resumed connections
|
||||
* without this module depending on the shared client (which would reintroduce the cycle).
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
public object TlsModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideKeyStoreImporter(): AndroidKeyStoreImporter = AndroidKeyStoreImporter()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideIdentityRepository(
|
||||
importer: AndroidKeyStoreImporter,
|
||||
certStore: CertStore,
|
||||
connectionPool: ConnectionPool,
|
||||
): IdentityRepository {
|
||||
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()'s
|
||||
// `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8).
|
||||
val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build()
|
||||
return AndroidIdentityRepository(importer, certStore, evictClient)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Like [runCatching] but NEVER swallows structured-concurrency cancellation: a [CancellationException]
|
||||
* is rethrown so a cancelled `produceState` / `LaunchedEffect` producer tears down cleanly, while any
|
||||
* real failure (a DataStore/transport error) degrades to a [Result.failure] the caller maps to a null /
|
||||
* fallback UI state. `inline` so the suspend calls in [block] inline into the caller's suspend context.
|
||||
*/
|
||||
internal inline fun <T> runCatchingCancellable(block: () -> T): Result<T> =
|
||||
try {
|
||||
Result.success(block())
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import wang.yaojia.webterm.wire.Validation
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* # DeepLinkRouter (A32) — the ONE deep-link validation surface.
|
||||
*
|
||||
* Android analogue of iOS `DeepLinkRouter` (`ios/App/WebTerm/DeepLinkRouter.swift`). Three external
|
||||
* entry points share ONE whitelist parser so there is never a second, drift-prone regex:
|
||||
*
|
||||
* - `webterminal://open?host=<uuid>&join=<uuid>` — the **custom scheme** (registered by the
|
||||
* `<intent-filter>` on the launcher Activity), and
|
||||
* - `https://<APP_LINK_HOST>/open?host=<uuid>&join=<uuid>` — the **verified App Link** (an
|
||||
* `autoVerify=true` `<intent-filter>`, gated by a `assetlinks.json` deploy artifact), and
|
||||
* - the FCM push-tap payload `{sessionId,…}` (A30 reuses [route] — same UUID rules, no second parser).
|
||||
*
|
||||
* ## Security contract (plan §8 "App Links", §1 deep-links row)
|
||||
* A deep link is EXTERNAL, untrusted input. Every field is whitelist-validated: scheme / action /
|
||||
* verified host / query keys, and BOTH ids as v4 UUIDs through the frozen [Validation.isValidSessionId]
|
||||
* (never re-regexed here — M7). ANY invalid, ambiguous (duplicate key), or missing part collapses the
|
||||
* whole link to [DeepLinkRoute.Ignore] — it is **never partially applied** (a valid host with a bad
|
||||
* session id does NOT open the host). Ignores are tallied by [DeepLinkTally], never echoed (the URL is
|
||||
* untrusted — log the counter only, never the link).
|
||||
*
|
||||
* ## Purity
|
||||
* PURE Kotlin/JVM — parses with `java.net.URI` (stdlib, not an Android import), exactly like
|
||||
* [wang.yaojia.webterm.wire.HostEndpoint]. The caller (the launcher Activity / the A30 push tap)
|
||||
* converts the platform `android.net.Uri`/`RemoteMessage.data` to a `String`/`Map` and hands it here,
|
||||
* so this whole surface is JVM-unit-testable with no device. Host EXISTENCE (is this a paired host?)
|
||||
* is resolved LATER against the `HostStore`, never here — the router only proves syntactic validity.
|
||||
*/
|
||||
public object DeepLinkRouter {
|
||||
|
||||
/** Custom-scheme name — case-insensitive per RFC 3986, compared lowercased. */
|
||||
public const val SCHEME: String = "webterminal"
|
||||
|
||||
/** Custom-scheme action (the URI "host" of `webterminal://open`). */
|
||||
public const val ACTION: String = "open"
|
||||
|
||||
/**
|
||||
* The verified App-Links domain. `assetlinks.json` must be served over HTTPS (no redirect) at
|
||||
* `https://<APP_LINK_HOST>/.well-known/assetlinks.json` carrying the RELEASE signing-cert SHA-256
|
||||
* (a deploy artifact, plan §8). The tunnel presents a real LE cert for this host.
|
||||
*/
|
||||
public const val APP_LINK_HOST: String = "terminal.yaojia.wang"
|
||||
|
||||
/** The App-Links path (the https analogue of the custom-scheme [ACTION]). */
|
||||
public const val APP_LINK_PATH: String = "/open"
|
||||
|
||||
/** Query key carrying the paired-host id (a v4 UUID resolved against the store later). */
|
||||
public const val QUERY_HOST: String = "host"
|
||||
|
||||
/** Query key carrying the session id to join (a v4 UUID). */
|
||||
public const val QUERY_JOIN: String = "join"
|
||||
|
||||
/** Push-payload key carrying the gate's session id (payload minimized to `sessionId/cls/token`, §8). */
|
||||
public const val PUSH_SESSION_ID: String = "sessionId"
|
||||
|
||||
/** URI paths accepted for the custom scheme (`webterminal://open` with an empty or root path). */
|
||||
private val EMPTY_PATHS: Set<String> = setOf("", "/")
|
||||
|
||||
/** URI paths accepted for the App Link (`/open`, with or without a trailing slash). */
|
||||
private val APP_LINK_PATHS: Set<String> = setOf(APP_LINK_PATH, "$APP_LINK_PATH/")
|
||||
|
||||
/**
|
||||
* Parse an incoming deep-link URI string. Handles BOTH the custom scheme and the verified App Link;
|
||||
* returns [DeepLinkRoute.OpenSession] only when the shape is whitelisted AND both ids are v4 UUIDs,
|
||||
* else [DeepLinkRoute.Ignore]. Never throws — a malformed URI is caught and ignored.
|
||||
*/
|
||||
public fun route(uri: String): DeepLinkRoute {
|
||||
val parsed = try {
|
||||
URI(uri)
|
||||
} catch (_: Exception) {
|
||||
return DeepLinkRoute.Ignore
|
||||
}
|
||||
if (!isWhitelistedShape(parsed)) return DeepLinkRoute.Ignore
|
||||
|
||||
val query = parseQuery(parsed.rawQuery)
|
||||
val hostId = uniqueValidatedId(query, QUERY_HOST) ?: return DeepLinkRoute.Ignore
|
||||
val sessionId = uniqueValidatedId(query, QUERY_JOIN) ?: return DeepLinkRoute.Ignore
|
||||
return DeepLinkRoute.OpenSession(hostId = hostId, sessionId = sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an FCM push-tap payload (A30). The payload is minimized to `sessionId/cls/token`; only a
|
||||
* v4-valid `sessionId` yields [DeepLinkRoute.GateSession] — there is no host id in the payload
|
||||
* (A30's notification handler owns host resolution). Anything else → [DeepLinkRoute.Ignore].
|
||||
*/
|
||||
public fun route(pushData: Map<String, String>): DeepLinkRoute {
|
||||
val sessionId = pushData[PUSH_SESSION_ID]?.let(::validatedId) ?: return DeepLinkRoute.Ignore
|
||||
return DeepLinkRoute.GateSession(sessionId = sessionId)
|
||||
}
|
||||
|
||||
/** True when [uri] matches the custom-scheme OR the verified-App-Link shape (scheme/host/path). */
|
||||
private fun isWhitelistedShape(uri: URI): Boolean {
|
||||
val scheme = uri.scheme?.lowercase() ?: return false
|
||||
val host = uri.host?.lowercase() ?: return false
|
||||
val path = uri.path ?: ""
|
||||
return when (scheme) {
|
||||
SCHEME -> host == ACTION && path in EMPTY_PATHS
|
||||
"https" -> host == APP_LINK_HOST && path in APP_LINK_PATHS
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly ONE occurrence of [key], and its value is a v4 UUID. Zero occurrences (missing key) or
|
||||
* duplicates (ambiguous input) → null, so the link is ignored rather than partially applied
|
||||
* (mirrors iOS `uniqueValidatedId`).
|
||||
*/
|
||||
private fun uniqueValidatedId(query: Map<String, List<String>>, key: String): String? {
|
||||
val values = query[key] ?: return null
|
||||
if (values.size != 1) return null
|
||||
return validatedId(values.first())
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen-contract v4 check FIRST ([Validation.isValidSessionId], M7). Returns the canonical
|
||||
* (unchanged) id string when valid, else null. UUIDs never need percent-decoding, so a value that
|
||||
* is not a bare v4 UUID (encoded, empty, garbage) simply fails the whitelist → ignored.
|
||||
*/
|
||||
private fun validatedId(raw: String): String? = if (Validation.isValidSessionId(raw)) raw else null
|
||||
|
||||
/**
|
||||
* Split a raw query string (`a=1&b=2&a=3`) into name → ordered values. Preserves duplicates so
|
||||
* [uniqueValidatedId] can reject ambiguous links. A segment without `=` maps to an empty value; an
|
||||
* empty/blank name is dropped. Values are NOT decoded (see [validatedId]).
|
||||
*/
|
||||
private fun parseQuery(rawQuery: String?): Map<String, List<String>> {
|
||||
if (rawQuery.isNullOrEmpty()) return emptyMap()
|
||||
val out = LinkedHashMap<String, MutableList<String>>()
|
||||
for (segment in rawQuery.split("&")) {
|
||||
if (segment.isEmpty()) continue
|
||||
val eq = segment.indexOf('=')
|
||||
val name = if (eq >= 0) segment.substring(0, eq) else segment
|
||||
val value = if (eq >= 0) segment.substring(eq + 1) else ""
|
||||
if (name.isEmpty()) continue
|
||||
out.getOrPut(name) { mutableListOf() }.add(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A deep-link parse outcome carrying RAW, syntactically-validated ids (host existence is resolved later
|
||||
* against the `HostStore`, never here). Mirrors iOS `DeepLinkRouter.Route`.
|
||||
*/
|
||||
public sealed interface DeepLinkRoute {
|
||||
/** `webterminal://open` / verified App Link with both ids valid v4 → open [sessionId] on [hostId]. */
|
||||
public data class OpenSession(val hostId: String, val sessionId: String) : DeepLinkRoute
|
||||
|
||||
/** Push-tap with a valid [sessionId] (no host id in the payload; A30 resolves the host). */
|
||||
public data class GateSession(val sessionId: String) : DeepLinkRoute
|
||||
|
||||
/** Non-whitelisted / ambiguous / invalid / missing input. Never partially applied; tallied. */
|
||||
public data object Ignore : DeepLinkRoute
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable tally of invalid deep links dropped (the Android analogue of iOS
|
||||
* `DeepLinkHandler.ignoredCount`). Kept as a pure reducer so counting stays side-effect-free and
|
||||
* JVM-testable; the app holds one instance in its state and swaps in the [record] result. The dropped
|
||||
* URL is NEVER stored — only the counter — because the link is untrusted external input (§8).
|
||||
*/
|
||||
public data class DeepLinkTally(val ignored: Int = 0) {
|
||||
/** Return a new tally: +1 when [route] is [DeepLinkRoute.Ignore], otherwise unchanged. */
|
||||
public fun record(route: DeepLinkRoute): DeepLinkTally =
|
||||
if (route is DeepLinkRoute.Ignore) copy(ignored = ignored + 1) else this
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
/**
|
||||
* # LayoutPolicy (A26) — the single adaptive-layout decision point.
|
||||
*
|
||||
* The Android mirror of iOS `LayoutPolicy` (`ios/App/WebTerm/Wiring/LayoutMode.swift`): the **only**
|
||||
* place that turns a window size class into a layout decision. Views must never scatter
|
||||
* `if windowSizeClass == …` — every branch reads this one pure predicate, so the decision is 100%
|
||||
* JVM-unit-testable with plain enum inputs (no Compose, no device — plan §"iPad-equivalent", R13).
|
||||
*
|
||||
* The thin `WindowSizeClass → WindowWidth` extraction lives in the Compose layer
|
||||
* ([wang.yaojia.webterm.screens.AdaptiveHome]); everything HERE is pure Kotlin so it stays in the
|
||||
* JVM test path exactly like iOS's `LayoutPolicyTests`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A plain, Compose-free mirror of `androidx.window.core.layout.WindowWidthSizeClass` (the analogue of
|
||||
* SwiftUI's `UserInterfaceSizeClass`). Kept as our own enum so [LayoutPolicy.mode] is a pure function
|
||||
* unit-tested without pulling the window/Compose types onto the JVM test classpath.
|
||||
*/
|
||||
public enum class WindowWidth {
|
||||
/** Phone portrait / small split / Slide-Over — the compact, single-pane path. */
|
||||
COMPACT,
|
||||
|
||||
/** Large phone landscape / small foldable / medium split — wide enough for sidebar + detail. */
|
||||
MEDIUM,
|
||||
|
||||
/** Tablet full-screen / large foldable / desktop-class window — sidebar + detail. */
|
||||
EXPANDED,
|
||||
}
|
||||
|
||||
/**
|
||||
* The adaptive root's layout mode (mirrors iOS `LayoutMode.stack` / `.split`).
|
||||
*
|
||||
* - [STACK] — the compact path: single-pane navigation (list → push terminal), byte-for-byte the
|
||||
* existing phone flow.
|
||||
* - [LIST_DETAIL] — the expanded/medium path: session list (list pane) + terminal (detail pane)
|
||||
* side by side, driven by Material 3 Adaptive `ListDetailPaneScaffold`.
|
||||
*/
|
||||
public enum class LayoutMode {
|
||||
/** Compact: single pane, stack navigation (iPhone / Slide-Over analogue). */
|
||||
STACK,
|
||||
|
||||
/** Medium / expanded: sidebar + detail split (iPad analogue). */
|
||||
LIST_DETAIL,
|
||||
}
|
||||
|
||||
/**
|
||||
* The one size-class decision. [WindowWidth.COMPACT] → [LayoutMode.STACK]; [WindowWidth.MEDIUM] and
|
||||
* [WindowWidth.EXPANDED] → [LayoutMode.LIST_DETAIL]. Pure; mirrors iOS
|
||||
* `LayoutPolicy.mode(horizontalSizeClass:)`.
|
||||
*/
|
||||
public object LayoutPolicy {
|
||||
public fun mode(width: WindowWidth): LayoutMode = when (width) {
|
||||
WindowWidth.COMPACT -> LayoutMode.STACK
|
||||
WindowWidth.MEDIUM, WindowWidth.EXPANDED -> LayoutMode.LIST_DETAIL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pointer-context-menu gating predicate (plan §5 A26, §"iPad-equivalent", R13, §8).
|
||||
*
|
||||
* A right-click / trackpad-secondary context menu is a large-screen affordance only — it is enabled
|
||||
* **iff** the layout is [LayoutMode.LIST_DETAIL] AND the device is a genuine tablet
|
||||
* (`smallestScreenWidthDp >= 600`, the Material tablet breakpoint). A large phone in landscape can
|
||||
* momentarily report an expanded width but keeps `smallestScreenWidthDp < 600`, so the two-part gate
|
||||
* blocks the desktop menu there. Pure so it is JVM-unit-tested with plain inputs.
|
||||
*/
|
||||
public object PointerMenuPolicy {
|
||||
/** The Material tablet breakpoint (dp). `smallestScreenWidthDp` at/above this = a real large screen. */
|
||||
public const val TABLET_MIN_WIDTH_DP: Int = 600
|
||||
|
||||
public fun enabled(mode: LayoutMode, smallestScreenWidthDp: Int): Boolean =
|
||||
mode == LayoutMode.LIST_DETAIL && smallestScreenWidthDp >= TABLET_MIN_WIDTH_DP
|
||||
}
|
||||
266
android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt
Normal file
266
android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt
Normal file
@@ -0,0 +1,266 @@
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||
import wang.yaojia.webterm.wiring.ColdStartRoute
|
||||
|
||||
/**
|
||||
* # WebTerm navigation host (app-assembly of A19–A29).
|
||||
*
|
||||
* The single `NavHost` for the app: it maps each [NavRoutes] destination onto the real screen pane
|
||||
* ([PairingPane] / [SessionsHome] / [ProjectsHome] / [TerminalHost] / [ProjectDetailPane] / [DiffPane] /
|
||||
* [ClientCertPane] / [GatePane]), wires the inter-screen callbacks to `navController` navigations, and
|
||||
* resolves every collaborator (ViewModels, per-host `ApiClient`, the terminal holder) off [env]. The
|
||||
* start destination is chosen by [wang.yaojia.webterm.wiring.ColdStartPolicy] (surfaced through
|
||||
* [NavRoutes.startRouteFor]) and passed in as [startRoute].
|
||||
*
|
||||
* ## Deep links go through [DeepLinkRouter], NOT `navDeepLink`
|
||||
* A `navDeepLink { uriPattern = "webterminal://open?host={host}&join={join}" }` would let the OS navigate
|
||||
* on ANY `{host}`/`{join}` value, bypassing the v4-UUID whitelist. Instead the launcher Activity (and the
|
||||
* push tap) run [DeepLinkRouter.route] first and navigate only a whitelist-validated [NavRoutes.forDeepLink]
|
||||
* route — the manifest `<intent-filter>`s deliver the intent; validation is never delegated to the graph.
|
||||
* That is the single non-skippable check (plan §8 "App Links").
|
||||
*
|
||||
* The `NavHost` composition itself is device-QA (plan §7); the pure route mappers ([NavRoutes]) are
|
||||
* JVM-unit-tested (`DeepLinkRouterTest`, `NavRoutesTest`).
|
||||
*/
|
||||
@Composable
|
||||
public fun WebTermNavHost(
|
||||
env: AppEnvironment,
|
||||
startRoute: String,
|
||||
navController: NavHostController = rememberNavController(),
|
||||
modifier: Modifier = Modifier,
|
||||
onHostPaired: (Host) -> Unit = {},
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startRoute,
|
||||
modifier = modifier,
|
||||
) {
|
||||
composable(NavRoutes.PAIRING) {
|
||||
PairingPane(
|
||||
env = env,
|
||||
onPaired = { host ->
|
||||
onHostPaired(host)
|
||||
navController.navigate(NavRoutes.SESSIONS) {
|
||||
popUpTo(NavRoutes.PAIRING) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onImportCert = { navController.navigate(NavRoutes.CERT) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(NavRoutes.SESSIONS) { SessionsHome(env = env, navController = navController) }
|
||||
|
||||
composable(NavRoutes.PROJECTS) { ProjectsHome(env = env, navController = navController) }
|
||||
|
||||
composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) }
|
||||
|
||||
composable(
|
||||
route = TERMINAL_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
|
||||
navArgument(NavArg.SESSION_ID) { type = NavType.StringType },
|
||||
navArgument(NavArg.CWD) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
) { entry ->
|
||||
val hostId = entry.arguments?.getString(NavArg.HOST_ID)
|
||||
TerminalHost(
|
||||
env = env,
|
||||
hostId = hostId,
|
||||
sessionArg = entry.arguments?.getString(NavArg.SESSION_ID),
|
||||
cwd = entry.arguments?.getString(NavArg.CWD),
|
||||
onNewSessionInCwd = { req ->
|
||||
if (hostId != null) navController.navigate(newTerminalRoute(hostId, req.cwd))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = PROJECT_DETAIL_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
|
||||
navArgument(NavArg.PATH) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
) { entry ->
|
||||
ProjectDetailPane(
|
||||
env = env,
|
||||
hostId = entry.arguments?.getString(NavArg.HOST_ID),
|
||||
path = entry.arguments?.getString(NavArg.PATH),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = DIFF_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
|
||||
navArgument(NavArg.PATH) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
) { entry ->
|
||||
DiffPane(
|
||||
env = env,
|
||||
hostId = entry.arguments?.getString(NavArg.HOST_ID),
|
||||
path = entry.arguments?.getString(NavArg.PATH),
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = NavRoutes.GATE_PATTERN,
|
||||
arguments = listOf(navArgument(NavArg.SESSION_ID) { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
GatePane(
|
||||
env = env,
|
||||
sessionArg = entry.arguments?.getString(NavArg.SESSION_ID),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registered routes carrying an OPTIONAL/encoded arg (kept OUT of the pure [NavRoutes] object) ──────
|
||||
|
||||
/** The registered terminal route: [NavRoutes.TERMINAL_PATTERN] plus an optional `cwd` query arg. */
|
||||
internal val TERMINAL_ROUTE: String = "${NavRoutes.TERMINAL_PATTERN}?${NavArg.CWD}={${NavArg.CWD}}"
|
||||
|
||||
/** The registered project-detail route: hostId path arg + an encoded `path` query arg. */
|
||||
internal val PROJECT_DETAIL_ROUTE: String = "projectDetail/{${NavArg.HOST_ID}}?${NavArg.PATH}={${NavArg.PATH}}"
|
||||
|
||||
/** The registered diff route: hostId path arg + an encoded `path` query arg. */
|
||||
internal val DIFF_ROUTE: String = "diff/{${NavArg.HOST_ID}}?${NavArg.PATH}={${NavArg.PATH}}"
|
||||
|
||||
/**
|
||||
* Build a concrete route that spawns a NEW session on [hostId] in [cwd] (`attach(null, cwd)`). The cwd
|
||||
* is percent-encoded with [Uri.encode] here (the Android layer) so [NavRoutes] stays a pure,
|
||||
* JVM-testable object with no `android.net.Uri` edge.
|
||||
*/
|
||||
internal fun newTerminalRoute(hostId: String, cwd: String?): String {
|
||||
val base = NavRoutes.terminalNew(hostId)
|
||||
return if (cwd.isNullOrEmpty()) base else "$base?${NavArg.CWD}=${Uri.encode(cwd)}"
|
||||
}
|
||||
|
||||
/** Build a concrete project-detail route for [hostId] + [path] (path percent-encoded). */
|
||||
internal fun projectDetailRoute(hostId: String, path: String): String =
|
||||
"projectDetail/$hostId?${NavArg.PATH}=${Uri.encode(path)}"
|
||||
|
||||
/** Build a concrete diff route for [hostId] + [path] (path percent-encoded). */
|
||||
internal fun diffRoute(hostId: String, path: String): String =
|
||||
"diff/$hostId?${NavArg.PATH}=${Uri.encode(path)}"
|
||||
|
||||
/**
|
||||
* Extract the layout [WindowWidth] from a Compose [WindowSizeClass] (the thin bridge kept out of the pure
|
||||
* [LayoutPolicy]). Mirrors the Material width breakpoints: EXPANDED (≥840dp) → medium (≥600dp) → compact.
|
||||
*/
|
||||
internal fun windowWidthOf(sizeClass: WindowSizeClass): WindowWidth = when {
|
||||
sizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> WindowWidth.EXPANDED
|
||||
sizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> WindowWidth.MEDIUM
|
||||
else -> WindowWidth.COMPACT
|
||||
}
|
||||
|
||||
/** Nav-arg keys, shared between the route patterns and the argument readers. */
|
||||
public object NavArg {
|
||||
public const val HOST_ID: String = "hostId"
|
||||
public const val SESSION_ID: String = "sessionId"
|
||||
|
||||
/** Optional new-session working directory (query arg on the terminal route). */
|
||||
public const val CWD: String = "cwd"
|
||||
|
||||
/** Project/diff path (encoded query arg). */
|
||||
public const val PATH: String = "path"
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's navigation destinations and the pure route mappers. Route strings are the ONE source of
|
||||
* truth for both [WebTermNavHost] and the callers that navigate; the mappers are pure (no Android edge)
|
||||
* so the cold-start and deep-link seams stay JVM-unit-testable without a device.
|
||||
*/
|
||||
public object NavRoutes {
|
||||
/** Pairing / add-host flow (A19) — the cold-start landing when no host is paired. */
|
||||
public const val PAIRING: String = "pairing"
|
||||
|
||||
/** Session chooser / dashboard (A20) — the cold-start landing when a host is paired. */
|
||||
public const val SESSIONS: String = "sessions"
|
||||
|
||||
/** Projects grid (A23). */
|
||||
public const val PROJECTS: String = "projects"
|
||||
|
||||
/** Device-certificate management (A27). */
|
||||
public const val CERT: String = "cert"
|
||||
|
||||
/** Terminal route pattern with the required host + session path args (A21). */
|
||||
public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}"
|
||||
|
||||
/**
|
||||
* Push-gate route pattern (A30): a session id with NO host id in the payload — the notification
|
||||
* handler resolves the host, then this destination surfaces the gate for [sessionId].
|
||||
*/
|
||||
public const val GATE_PATTERN: String = "gate/{${NavArg.SESSION_ID}}"
|
||||
|
||||
/**
|
||||
* Sentinel session-arg meaning "spawn a NEW session" (`attach(null)`) — a fresh session has no
|
||||
* server id yet, so the terminal route carries this in the [NavArg.SESSION_ID] slot and
|
||||
* [attachSessionId] maps it back to `null` at the bind site.
|
||||
*/
|
||||
public const val NEW_SESSION_SENTINEL: String = "new"
|
||||
|
||||
/**
|
||||
* The cold-start start destination, keyed off [ColdStartRoute] (no host → pairing, else sessions).
|
||||
* Pure; mirrors the [wang.yaojia.webterm.wiring.ColdStartPolicy] decision onto a nav route.
|
||||
*/
|
||||
public fun startRouteFor(route: ColdStartRoute): String = when (route) {
|
||||
ColdStartRoute.PAIRING -> PAIRING
|
||||
ColdStartRoute.SESSIONS -> SESSIONS
|
||||
}
|
||||
|
||||
/** Build a concrete terminal route for a resolved (hostId, sessionId) pair. */
|
||||
public fun terminal(hostId: String, sessionId: String): String = "terminal/$hostId/$sessionId"
|
||||
|
||||
/** Build a concrete terminal route that spawns a NEW session on [hostId] ([NEW_SESSION_SENTINEL]). */
|
||||
public fun terminalNew(hostId: String): String = "terminal/$hostId/$NEW_SESSION_SENTINEL"
|
||||
|
||||
/**
|
||||
* Map a terminal-route session arg to the id to bind: the [NEW_SESSION_SENTINEL] (a fresh spawn)
|
||||
* becomes `null` (`attach(null)`), any other value passes through verbatim. Pure → JVM-tested.
|
||||
*/
|
||||
public fun attachSessionId(sessionArg: String?): String? =
|
||||
sessionArg?.takeUnless { it == NEW_SESSION_SENTINEL }
|
||||
|
||||
/** Build a concrete push-gate route for a resolved sessionId. */
|
||||
public fun gate(sessionId: String): String = "gate/$sessionId"
|
||||
|
||||
/**
|
||||
* The deep-link entry point: turn a [DeepLinkRouter] result into the concrete nav route to navigate
|
||||
* to, or null for [DeepLinkRoute.Ignore] (the caller does nothing — the ignore is already tallied).
|
||||
* This is the ONE place a validated deep link becomes a navigation, so the launcher Activity and the
|
||||
* push tap share it. Pure → JVM-tested.
|
||||
*/
|
||||
public fun forDeepLink(route: DeepLinkRoute): String? = when (route) {
|
||||
is DeepLinkRoute.OpenSession -> terminal(route.hostId, route.sessionId)
|
||||
is DeepLinkRoute.GateSession -> gate(route.sessionId)
|
||||
DeepLinkRoute.Ignore -> null
|
||||
}
|
||||
}
|
||||
236
android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt
Normal file
236
android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt
Normal file
@@ -0,0 +1,236 @@
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.navigation.NavHostController
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.screens.ClientCertScreen
|
||||
import wang.yaojia.webterm.screens.DiffScreen
|
||||
import wang.yaojia.webterm.screens.PairingScreen
|
||||
import wang.yaojia.webterm.screens.ProjectDetailScreen
|
||||
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
|
||||
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
||||
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
||||
import wang.yaojia.webterm.viewmodels.HttpDiffFetcher
|
||||
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
||||
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
|
||||
import wang.yaojia.webterm.viewmodels.ProjectsGateway
|
||||
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||
import java.util.UUID
|
||||
|
||||
// ── Pairing (A19) ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hosts [PairingScreen] over a fresh [PairingViewModel]: the two-step probe uses the shared transports
|
||||
* (resolved lazily off `Main` inside the probe), and the tunnel cert-gate reads the device identity off
|
||||
* `Dispatchers.IO`. The screen does not self-bind, so this pane binds it to a lifecycle scope.
|
||||
*/
|
||||
@Composable
|
||||
public fun PairingPane(
|
||||
env: AppEnvironment,
|
||||
onPaired: (Host) -> Unit,
|
||||
onImportCert: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = remember(env) {
|
||||
PairingViewModel(
|
||||
hostStore = env.hostStore,
|
||||
prober = env.buildPairingProber(),
|
||||
hasDeviceCert = {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||
PairingScreen(viewModel = viewModel, onPaired = onPaired, onImportCert = onImportCert, modifier = modifier)
|
||||
}
|
||||
|
||||
// ── Device certificate (A27) ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Hosts [ClientCertScreen] over a fresh [ClientCertViewModel] on the shared mTLS device identity. */
|
||||
@Composable
|
||||
public fun ClientCertPane(
|
||||
env: AppEnvironment,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = remember(env) { ClientCertViewModel(repository = env.identityRepository) }
|
||||
ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
|
||||
}
|
||||
|
||||
// ── Project detail (A23) ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The project-detail destination: resolves [hostId] → its per-host projects gateway, then renders
|
||||
* [ProjectDetailContent]. "在此启动 Claude" spawns a new session in the project cwd.
|
||||
*/
|
||||
@Composable
|
||||
public fun ProjectDetailPane(
|
||||
env: AppEnvironment,
|
||||
hostId: String?,
|
||||
path: String?,
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (hostId == null || path == null) {
|
||||
MessagePane("无效的项目。", modifier)
|
||||
return
|
||||
}
|
||||
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
|
||||
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
|
||||
}
|
||||
val resolved = host
|
||||
if (resolved == null) {
|
||||
MessagePane("正在打开项目…", modifier)
|
||||
return
|
||||
}
|
||||
val gateway = remember(resolved) { ApiClientProjectsGateway(env.apiClientFactory.create(resolved.endpoint)) }
|
||||
ProjectDetailContent(
|
||||
gateway = gateway,
|
||||
path = path,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared project-detail body — reused by the stacked destination and the tablet detail pane. */
|
||||
@Composable
|
||||
public fun ProjectDetailContent(
|
||||
gateway: ProjectsGateway,
|
||||
path: String,
|
||||
onBack: () -> Unit,
|
||||
onOpenClaude: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
|
||||
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier)
|
||||
}
|
||||
|
||||
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The read-only git-diff destination (A24): resolves [hostId] → the RO diff fetcher over the shared
|
||||
* [HttpTransport][wang.yaojia.webterm.wire.HttpTransport]. Composed for completeness; no inbound link is
|
||||
* wired yet (the project-detail screen exposes no "view diff" callback to connect).
|
||||
*/
|
||||
@Composable
|
||||
public fun DiffPane(
|
||||
env: AppEnvironment,
|
||||
hostId: String?,
|
||||
path: String?,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (hostId == null || path == null) {
|
||||
MessagePane("无效的路径。", modifier)
|
||||
return
|
||||
}
|
||||
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
|
||||
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
|
||||
}
|
||||
val resolved = host
|
||||
if (resolved == null) {
|
||||
MessagePane("正在加载 diff…", modifier)
|
||||
return
|
||||
}
|
||||
val viewModel = remember(resolved, path) {
|
||||
DiffViewModel(fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), path = path)
|
||||
}
|
||||
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
|
||||
}
|
||||
|
||||
// ── Push-gate deep link (A30) ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The push-gate destination: a valid session id arrives with NO host (payload minimization, §4.5), so this
|
||||
* best-effort scans the paired hosts' live sessions for a match and redirects to that host's terminal
|
||||
* (which surfaces the held gate); if none matches, it falls back to the sessions chooser. All resolution
|
||||
* runs off `Main`; device-QA.
|
||||
*/
|
||||
@Composable
|
||||
public fun GatePane(
|
||||
env: AppEnvironment,
|
||||
sessionArg: String?,
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sessionId = NavRoutes.attachSessionId(sessionArg)
|
||||
LaunchedEffect(sessionId) {
|
||||
val route = gateTargetRoute(env, sessionId)
|
||||
runCatching {
|
||||
navController.navigate(route) {
|
||||
popUpTo(NavRoutes.SESSIONS) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
MessagePane("正在定位会话…", modifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where a push-gate deep link should land: the terminal of the host that currently owns
|
||||
* [sessionId] (which surfaces the held gate), or the sessions chooser when the id is missing/invalid or
|
||||
* no paired host has it.
|
||||
*/
|
||||
private suspend fun gateTargetRoute(env: AppEnvironment, sessionId: String?): String {
|
||||
if (sessionId == null) return NavRoutes.SESSIONS
|
||||
val uuid = runCatching { UUID.fromString(sessionId) }.getOrNull() ?: return NavRoutes.SESSIONS
|
||||
val hostId = resolveHostForSession(env, uuid) ?: return NavRoutes.SESSIONS
|
||||
return NavRoutes.terminal(hostId, sessionId)
|
||||
}
|
||||
|
||||
/** Scan paired hosts' `GET /live-sessions` for [id]; return the first host that has it (off `Main`). */
|
||||
private suspend fun resolveHostForSession(env: AppEnvironment, id: UUID): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
for (host in runCatchingCancellable { env.hostStore.loadAll() }.getOrDefault(emptyList())) {
|
||||
val hasIt = runCatchingCancellable {
|
||||
env.apiClientFactory.create(host.endpoint).liveSessions().any { it.id == id }
|
||||
}.getOrDefault(false)
|
||||
if (hasIt) return@withContext host.id
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
// ── Shared placeholders ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Centered informational text for the tablet detail pane's empty state. */
|
||||
@Composable
|
||||
public fun DetailPlaceholder(text: String, modifier: Modifier = Modifier) {
|
||||
CenteredMessage(text, modifier)
|
||||
}
|
||||
|
||||
/** Centered informational text for a loading / invalid-argument pane. */
|
||||
@Composable
|
||||
public fun MessagePane(text: String, modifier: Modifier = Modifier) {
|
||||
CenteredMessage(text, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(text: String, modifier: Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(Spacing.xxl24),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.screens.AdaptiveHome
|
||||
import wang.yaojia.webterm.screens.ProjectsScreen
|
||||
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
|
||||
import wang.yaojia.webterm.viewmodels.ProjectsViewModel
|
||||
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||
|
||||
/**
|
||||
* The Projects grid (A23) inside the A26 adaptive shell: [ProjectsScreen] (list pane) beside the selected
|
||||
* [ProjectDetailContent] (detail pane) on large screens, or a single grid on compact where a card tap
|
||||
* pushes the project-detail destination. "在此启动 Claude" (from a card or the detail) is routed through
|
||||
* [ProjectsViewModel.requestOpenClaude] → a validated [ProjectOpenRequest], which this pane consumes into
|
||||
* a `newTerminalRoute` navigation (`attach(null, cwd=projectPath)`).
|
||||
*
|
||||
* Projects run against the FIRST paired host (single-host is the common case; cross-host project selection
|
||||
* is a device-QA refinement). The navigation-suite "会话" entry returns to [SessionsHome].
|
||||
*/
|
||||
@Composable
|
||||
public fun ProjectsHome(
|
||||
env: AppEnvironment,
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val host by produceState<Host?>(initialValue = null) {
|
||||
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull() }.getOrNull()
|
||||
}
|
||||
val resolved = host
|
||||
if (resolved == null) {
|
||||
DetailPlaceholder("尚未配对主机。", modifier)
|
||||
return
|
||||
}
|
||||
|
||||
val gateway = remember(resolved) { ApiClientProjectsGateway(env.apiClientFactory.create(resolved.endpoint)) }
|
||||
val viewModel = remember(gateway) { ProjectsViewModel(gateway) }
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// "Open Claude here" → spawn a new session in the project cwd, then clear the one-shot signal.
|
||||
LaunchedEffect(state.openRequest) {
|
||||
val request = state.openRequest ?: return@LaunchedEffect
|
||||
navController.navigate(newTerminalRoute(resolved.id, request.cwd))
|
||||
viewModel.consumeOpenRequest()
|
||||
}
|
||||
|
||||
val sizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
val mode = LayoutPolicy.mode(windowWidthOf(sizeClass))
|
||||
var selectedPath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
AdaptiveHome(
|
||||
listPane = {
|
||||
ProjectsScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenDetail = { path ->
|
||||
if (mode == LayoutMode.LIST_DETAIL) {
|
||||
selectedPath = path
|
||||
} else {
|
||||
navController.navigate(projectDetailRoute(resolved.id, path))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
detailPane = {
|
||||
val path = selectedPath
|
||||
if (path != null) {
|
||||
ProjectDetailContent(
|
||||
gateway = gateway,
|
||||
path = path,
|
||||
onBack = { selectedPath = null },
|
||||
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
||||
)
|
||||
} else {
|
||||
DetailPlaceholder("选择一个项目查看详情。")
|
||||
}
|
||||
},
|
||||
selectedDestination = 1,
|
||||
onSelectDestination = { index ->
|
||||
if (index == 0) {
|
||||
navController.navigate(NavRoutes.SESSIONS) {
|
||||
popUpTo(NavRoutes.SESSIONS) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
},
|
||||
windowSizeClass = sizeClass,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import wang.yaojia.webterm.components.ContinueLastBanner
|
||||
import wang.yaojia.webterm.components.continueLastModel
|
||||
import wang.yaojia.webterm.screens.AdaptiveHome
|
||||
import wang.yaojia.webterm.screens.SessionListScreen
|
||||
import wang.yaojia.webterm.viewmodels.ApiClientSessionGateway
|
||||
import wang.yaojia.webterm.viewmodels.SessionListViewModel
|
||||
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The sessions landing (A20) inside the A26 adaptive shell: a [SessionListScreen] (list pane) beside the
|
||||
* live [TerminalHost] (detail pane) on large screens, or a single list on compact where a row tap pushes
|
||||
* the terminal destination. The [ContinueLastBanner] (A29) is layered above the list. The navigation-suite
|
||||
* "项目" entry navigates to [ProjectsHome].
|
||||
*
|
||||
* ### Adaptive open behaviour
|
||||
* On [LayoutMode.LIST_DETAIL] a row tap fills the detail pane (a per-session holder keyed under THIS
|
||||
* back-stack entry); on [LayoutMode.STACK] it pushes a full-screen terminal destination. New-session and
|
||||
* continue-last always push, so a fresh spawn gets its own nav-scoped holder.
|
||||
*
|
||||
* The [SessionListViewModel] host menu drives its own active-host selection; this pane reads the resolved
|
||||
* active host id from its state to build the terminal endpoint / continue-last pointer.
|
||||
*/
|
||||
@Composable
|
||||
public fun SessionsHome(
|
||||
env: AppEnvironment,
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = remember(env) {
|
||||
SessionListViewModel(
|
||||
hostStore = env.hostStore,
|
||||
gatewayFactory = { host -> ApiClientSessionGateway(env.apiClientFactory.create(host.endpoint)) },
|
||||
)
|
||||
}
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val activeHostId = state.activeHostId
|
||||
|
||||
val sizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
val mode = LayoutPolicy.mode(windowWidthOf(sizeClass))
|
||||
var selectedSessionId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val openSession: (UUID) -> Unit = { id ->
|
||||
val sid = id.toString()
|
||||
if (mode == LayoutMode.LIST_DETAIL) {
|
||||
selectedSessionId = sid
|
||||
} else {
|
||||
activeHostId?.let { navController.navigate(NavRoutes.terminal(it, sid)) }
|
||||
}
|
||||
}
|
||||
val openNewSession: () -> Unit = {
|
||||
activeHostId?.let { navController.navigate(NavRoutes.terminalNew(it)) }
|
||||
}
|
||||
|
||||
AdaptiveHome(
|
||||
listPane = {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
val lastSessionId by produceState<String?>(initialValue = null, key1 = activeHostId) {
|
||||
value = activeHostId?.let { runCatchingCancellable { env.lastSessionStore.lastSessionId(it) }.getOrNull() }
|
||||
}
|
||||
ContinueLastBanner(
|
||||
model = continueLastModel(lastSessionId),
|
||||
onContinue = { sid -> activeHostId?.let { navController.navigate(NavRoutes.terminal(it, sid)) } },
|
||||
)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
SessionListScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenSession = openSession,
|
||||
onNewSession = openNewSession,
|
||||
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
|
||||
onImportCert = { navController.navigate(NavRoutes.CERT) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
val sid = selectedSessionId
|
||||
if (sid != null && activeHostId != null) {
|
||||
TerminalHost(
|
||||
env = env,
|
||||
hostId = activeHostId,
|
||||
sessionArg = sid,
|
||||
cwd = null,
|
||||
onNewSessionInCwd = { req ->
|
||||
navController.navigate(newTerminalRoute(activeHostId, req.cwd))
|
||||
},
|
||||
holderKey = "detail/$activeHostId/$sid",
|
||||
)
|
||||
} else {
|
||||
DetailPlaceholder("选择一个会话查看终端。")
|
||||
}
|
||||
},
|
||||
selectedDestination = 0,
|
||||
onSelectDestination = { index ->
|
||||
if (index == 1) navController.navigate(NavRoutes.PROJECTS) { launchSingleTop = true }
|
||||
},
|
||||
windowSizeClass = sizeClass,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.screens.NewSessionRequest
|
||||
import wang.yaojia.webterm.screens.TerminalScreen
|
||||
import wang.yaojia.webterm.screens.TimelineSheet
|
||||
import wang.yaojia.webterm.viewmodels.TimelineViewModel
|
||||
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||
import wang.yaojia.webterm.wiring.RetainedSessionHolder
|
||||
import wang.yaojia.webterm.wiring.SessionActivityBridge
|
||||
import java.util.UUID
|
||||
|
||||
/** How long (steps × step-ms) to wait for the holder to publish its controller before giving up. */
|
||||
private const val BRIDGE_WAIT_STEPS: Int = 200
|
||||
private const val BRIDGE_WAIT_STEP_MS: Long = 50L
|
||||
|
||||
/**
|
||||
* # TerminalHost — the composition-root host for one terminal destination (A21/A22/A28/A29).
|
||||
*
|
||||
* Resolves [hostId] → its paired [Host] (for the dial endpoint), obtains the config-surviving
|
||||
* [RetainedSessionHolder] (per (host, session) via [hiltViewModel] — the nav back-stack entry is the
|
||||
* `ViewModelStoreOwner`; [holderKey] scopes a distinct holder when several are hosted under ONE owner,
|
||||
* e.g. the tablet detail pane), and renders [TerminalScreen]. It adds the two composition-root wirings
|
||||
* A21 left as seams:
|
||||
*
|
||||
* - **A29 last-session lifecycle** — a per-session [SessionActivityBridge] folds the controller's
|
||||
* control-event stream into [LastSessionStore][wang.yaojia.webterm.hostregistry.LastSessionStore]
|
||||
* (set-on-`Adopted`, clear-on-`Exited`), so cold-start's「继续上次会话」points at the live session.
|
||||
* - **A28 timeline** — the away-digest「展开」opens the [TimelineSheet] for the current session, its
|
||||
* [TimelineViewModel] built over the per-host `ApiClient.events`.
|
||||
*
|
||||
* All Compose/lifecycle/emulator behaviour is device-QA (plan §7).
|
||||
*
|
||||
* @param sessionArg the raw route session arg ([NavRoutes.NEW_SESSION_SENTINEL] = spawn a new session).
|
||||
* @param cwd new-session working directory (only meaningful when spawning; ignored on re-attach).
|
||||
* @param onNewSessionInCwd nav callback for the toolbar/exit "在当前目录开新会话" action.
|
||||
* @param holderKey optional [hiltViewModel] key so the tablet detail pane can host a holder distinct
|
||||
* from the stacked terminal destination under the SAME owner (null = the owner's default holder).
|
||||
*/
|
||||
@Composable
|
||||
public fun TerminalHost(
|
||||
env: AppEnvironment,
|
||||
hostId: String?,
|
||||
sessionArg: String?,
|
||||
cwd: String?,
|
||||
onNewSessionInCwd: (NewSessionRequest) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
holderKey: String? = null,
|
||||
) {
|
||||
if (hostId == null) {
|
||||
MessagePane("无效的主机。", modifier)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the paired host for its dial endpoint (untrusted at rest; a removed host stays "loading").
|
||||
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
|
||||
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
|
||||
}
|
||||
val resolved = host
|
||||
if (resolved == null) {
|
||||
MessagePane("正在打开会话…", modifier)
|
||||
return
|
||||
}
|
||||
|
||||
val sessionId = NavRoutes.attachSessionId(sessionArg)
|
||||
val holder: RetainedSessionHolder = hiltViewModel(key = holderKey ?: "$hostId/$sessionArg")
|
||||
|
||||
// A29: drive LastSessionStore off the session's control-event stream once the holder publishes its
|
||||
// controller (bind() sets it during TerminalScreen's composition, after warm-up). A bounded wait
|
||||
// avoids a busy-spin if warm-up fails; the collection is cancelled when this composable leaves.
|
||||
LaunchedEffect(holder, hostId) {
|
||||
var controller = holder.controller
|
||||
var waited = 0
|
||||
while (controller == null && waited < BRIDGE_WAIT_STEPS) {
|
||||
delay(BRIDGE_WAIT_STEP_MS)
|
||||
controller = holder.controller
|
||||
waited++
|
||||
}
|
||||
controller?.let { live ->
|
||||
SessionActivityBridge(lastSessionStore = env.lastSessionStore, hostId = hostId)
|
||||
.observe(live.controlEvents())
|
||||
}
|
||||
}
|
||||
|
||||
var showTimeline by remember(hostId, sessionArg) { mutableStateOf(false) }
|
||||
|
||||
TerminalScreen(
|
||||
holder = holder,
|
||||
endpoint = resolved.endpoint,
|
||||
sessionId = sessionId,
|
||||
cwd = cwd,
|
||||
onNewSessionInCwd = onNewSessionInCwd,
|
||||
modifier = modifier,
|
||||
onWarmUp = { env.warmUp() },
|
||||
onDigestExpand = { if (sessionId != null) showTimeline = true },
|
||||
)
|
||||
|
||||
if (showTimeline && sessionId != null) {
|
||||
val sessionUuid = remember(sessionId) { runCatching { UUID.fromString(sessionId) }.getOrNull() }
|
||||
val api = remember(resolved) { env.apiClientFactory.create(resolved.endpoint) }
|
||||
// A fresh VM each time the sheet opens (this block re-enters composition) → exactly one fetch.
|
||||
val timelineVm = remember { TimelineViewModel.forSession(sessionUuid) { id -> api.events(id) } }
|
||||
if (timelineVm != null) {
|
||||
TimelineSheet(viewModel = timelineVm, onDismiss = { showTimeline = false })
|
||||
} else {
|
||||
showTimeline = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.wiring.ApiClientFactory
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Hosts the **Allow** action's BiometricPrompt (plan §8 / R1 mustFix). A `BroadcastReceiver` or
|
||||
* `Service` CANNOT present a `BiometricPrompt` (it needs a `FragmentActivity` and would blow
|
||||
* `goAsync()`'s ~10 s budget), so Allow — unlike the auth-free Deny — trampolines through this
|
||||
* translucent, `excludeFromRecents` Activity (both set in the manifest). It shows only the system
|
||||
* biometric sheet; on success it fires `POST /hook/decision {decision:"allow"}`, then finishes.
|
||||
*
|
||||
* The single-use token arrives in the `FLAG_IMMUTABLE` PendingIntent extras and is handed straight
|
||||
* to [PushDecisionSubmitter] — never logged, never persisted, never rendered. Any cancel/error/no-auth
|
||||
* path finishes WITHOUT posting (fail-safe: no approval without a fresh biometric).
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
public class AllowTrampolineActivity : FragmentActivity() {
|
||||
|
||||
@Inject
|
||||
public lateinit var hostStore: HostStore
|
||||
|
||||
@Inject
|
||||
public lateinit var apiClientFactory: ApiClientFactory
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val sessionId = intent.getStringExtra(NotificationBuilder.EXTRA_SESSION_ID)
|
||||
?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||
val token = intent.getStringExtra(NotificationBuilder.EXTRA_TOKEN)
|
||||
val notificationId = intent.getIntExtra(NotificationBuilder.EXTRA_NOTIFICATION_ID, INVALID_ID)
|
||||
if (sessionId == null || token.isNullOrEmpty()) {
|
||||
Log.w(TAG, "allow: missing session id or token; finishing")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (!canAuthenticate()) {
|
||||
Log.w(TAG, "allow: no biometric/credential available; finishing without approving")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
promptThenAllow(sessionId, token, notificationId)
|
||||
}
|
||||
|
||||
private fun promptThenAllow(sessionId: UUID, token: String, notificationId: Int) {
|
||||
val prompt = BiometricPrompt(
|
||||
this,
|
||||
ContextCompat.getMainExecutor(this),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
approveThenFinish(sessionId, token, notificationId)
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
// User cancelled / lockout / hardware error → do NOT approve. Fail-safe.
|
||||
Log.d(TAG, "allow: auth error $errorCode; finishing without approving")
|
||||
finish()
|
||||
}
|
||||
// onAuthenticationFailed (a single non-match) intentionally left to retry in-prompt.
|
||||
},
|
||||
)
|
||||
prompt.authenticate(promptInfo())
|
||||
}
|
||||
|
||||
private fun approveThenFinish(sessionId: UUID, token: String, notificationId: Int) {
|
||||
val submitter = PushDecisionSubmitter.fromRegistry(hostStore, apiClientFactory) { Log.d(TAG, it) }
|
||||
lifecycleScope.launch {
|
||||
// mTLS build + network off the main thread; finish back on main.
|
||||
withContext(Dispatchers.IO) { submitter.submit(sessionId, HookDecision.ALLOW, token) }
|
||||
NotificationManagerCompat.from(this@AllowTrampolineActivity).cancel(notificationId)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canAuthenticate(): Boolean =
|
||||
BiometricManager.from(this).canAuthenticate(ALLOWED_AUTHENTICATORS) ==
|
||||
BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
private fun promptInfo(): BiometricPrompt.PromptInfo =
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(PROMPT_TITLE)
|
||||
.setSubtitle(PROMPT_SUBTITLE)
|
||||
.setAllowedAuthenticators(ALLOWED_AUTHENTICATORS)
|
||||
.setNegativeButtonText(PROMPT_NEGATIVE)
|
||||
.build()
|
||||
|
||||
private companion object {
|
||||
const val TAG = "AllowTrampoline"
|
||||
const val INVALID_ID = -1
|
||||
// BIOMETRIC_STRONG only (+ a negative button) — the one combination valid on API 29+.
|
||||
// Device-credential fallback is a §7 device-QA refinement (can't mix with a negative button).
|
||||
const val ALLOWED_AUTHENTICATORS = Authenticators.BIOMETRIC_STRONG
|
||||
const val PROMPT_TITLE = "Approve action"
|
||||
const val PROMPT_SUBTITLE = "Confirm to allow the pending action"
|
||||
const val PROMPT_NEGATIVE = "Cancel"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.wiring.ApiClientFactory
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Handles the notification **Deny** action (plan §8 notification-action-trust-split). Deny is
|
||||
* **auth-free and fail-safe**: no BiometricPrompt, no UI, no Activity — it just fires the expedited
|
||||
* `POST /hook/decision {decision:"deny"}` and dismisses the card. A `BroadcastReceiver` is the right
|
||||
* host precisely because Deny needs no biometric gate (Allow does — [AllowTrampolineActivity]).
|
||||
*
|
||||
* [goAsync] extends the ~10 s receiver budget so the one-shot POST can finish off the main thread.
|
||||
* The single-use token arrives via the `FLAG_IMMUTABLE` PendingIntent extras and is handed straight
|
||||
* to [PushDecisionSubmitter] — never logged, never persisted.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
public class DenyBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
public lateinit var hostStore: HostStore
|
||||
|
||||
@Inject
|
||||
public lateinit var apiClientFactory: ApiClientFactory
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val sessionId = intent.getStringExtra(NotificationBuilder.EXTRA_SESSION_ID)
|
||||
?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||
val token = intent.getStringExtra(NotificationBuilder.EXTRA_TOKEN)
|
||||
val notificationId = intent.getIntExtra(NotificationBuilder.EXTRA_NOTIFICATION_ID, INVALID_ID)
|
||||
if (sessionId == null || token.isNullOrEmpty()) {
|
||||
Log.w(TAG, "deny: missing session id or token; ignoring")
|
||||
dismiss(context, notificationId)
|
||||
return
|
||||
}
|
||||
|
||||
val pending = goAsync()
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val submitter = PushDecisionSubmitter.fromRegistry(hostStore, apiClientFactory) { Log.d(TAG, it) }
|
||||
scope.launch {
|
||||
try {
|
||||
submitter.submit(sessionId, HookDecision.DENY, token)
|
||||
} finally {
|
||||
dismiss(context, notificationId)
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismiss(context: Context, notificationId: Int) {
|
||||
if (notificationId != INVALID_ID) NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DenyReceiver"
|
||||
const val INVALID_ID = -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.util.Optional
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The FCM entry point (plan §1 P1 push / §4.5 / A30). The server sends **data-only** high-priority
|
||||
* messages (no server `notification` block); this service builds the lock-screen UI LOCALLY:
|
||||
*
|
||||
* - [onMessageReceived] parses the untrusted data map into a [PushPayload], maps `cls` → a
|
||||
* [NotificationBuilder.NotificationPlan], and posts it. A gate (`needs-input`) card carries the
|
||||
* Allow/Deny actions with their `FLAG_IMMUTABLE` intents; `done` is informational.
|
||||
* - [onNewToken] forwards the rotated registration token to the A31 [PushTokenSink] seam — this
|
||||
* service does NOT implement registration (that is A31); it only calls the injected hook.
|
||||
*
|
||||
* Injected via Hilt (`@AndroidEntryPoint`). The single-use capability token is only ever placed into
|
||||
* the action PendingIntents by [NotificationBuilder]; it is never logged here.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
public class FcmService : FirebaseMessagingService() {
|
||||
|
||||
/** A31 seam: forward token rotations to the registrar. `Optional.empty()` until A31 binds it. */
|
||||
@Inject
|
||||
public lateinit var pushTokenSink: Optional<PushTokenSink>
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
val payload = PushPayload.parse(message.data)
|
||||
if (payload == null) {
|
||||
Log.w(TAG, "dropping malformed push payload")
|
||||
return
|
||||
}
|
||||
val plan = NotificationBuilder.plan(payload.cls)
|
||||
if (plan == null) {
|
||||
Log.d(TAG, "no notification for class '${payload.cls}'")
|
||||
return
|
||||
}
|
||||
if (!hasNotificationPermission()) {
|
||||
Log.w(TAG, "POST_NOTIFICATIONS not granted; skipping push notification")
|
||||
return
|
||||
}
|
||||
postNotification(payload, plan)
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
// Do NOT implement registration here (A31). Just hand the token to the seam if A31 bound one.
|
||||
if (pushTokenSink.isPresent) pushTokenSink.get().onTokenRefreshed(token)
|
||||
}
|
||||
|
||||
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
private fun postNotification(payload: PushPayload, plan: NotificationBuilder.NotificationPlan) {
|
||||
val notification = NotificationBuilder.build(this, payload, plan)
|
||||
val id = NotificationBuilder.notificationIdFor(payload.sessionId.toString())
|
||||
NotificationManagerCompat.from(this).notify(id, notification)
|
||||
}
|
||||
|
||||
/** POST_NOTIFICATIONS is a runtime permission on API 33+; below that it is implicitly granted. */
|
||||
private fun hasNotificationPermission(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private companion object {
|
||||
const val TAG = "FcmService"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import wang.yaojia.webterm.MainActivity
|
||||
|
||||
/**
|
||||
* Builds the lock-screen notification LOCALLY (FCM sends data-only; there is no server `notification`
|
||||
* block — plan §4.5 / R1). The `cls` → channel/title/actions mapping is a PURE function ([plan]) so
|
||||
* it is JVM-unit-testable and so the trust split is decided in one place:
|
||||
*
|
||||
* - `needs-input` (a held gate) → high-importance channel + **Allow** and **Deny** actions.
|
||||
* - `done` (finished) → default-importance channel, informational, **no actions**.
|
||||
* - anything else → null (unknown class ⇒ show nothing).
|
||||
*
|
||||
* ### Content minimization (plan §4.5 / §8)
|
||||
* Every visible string ([NotificationPlan.title]/[NotificationPlan.text]) is a static constant derived
|
||||
* from `cls` ALONE — never the token, cwd, command, or terminal bytes. The single-use token travels
|
||||
* only inside the action [PendingIntent] extras (Deny → receiver, Allow → trampoline), which are
|
||||
* `FLAG_IMMUTABLE` (plan §8), and is handed straight to the POST — never rendered, persisted, or logged.
|
||||
*/
|
||||
public object NotificationBuilder {
|
||||
|
||||
/** The local action buttons a notification can carry (decided by `cls` only). */
|
||||
public enum class Action { ALLOW, DENY }
|
||||
|
||||
/**
|
||||
* The Android-free description of a notification, derived purely from `cls`. [highImportance]
|
||||
* maps to the channel importance in [build]; keeping it a boolean (not a `NotificationManager`
|
||||
* constant) keeps this value pure and JVM-testable.
|
||||
*/
|
||||
public data class NotificationPlan(
|
||||
public val channelId: String,
|
||||
public val channelName: String,
|
||||
public val highImportance: Boolean,
|
||||
public val title: String,
|
||||
public val text: String,
|
||||
public val actions: List<Action>,
|
||||
)
|
||||
|
||||
// ── Pure mapping (JVM-tested) ────────────────────────────────────────────────────────────
|
||||
|
||||
/** `cls` → [NotificationPlan], or null for an unknown class. Purely a function of [cls]. */
|
||||
public fun plan(cls: String): NotificationPlan? =
|
||||
when (cls) {
|
||||
PushPayload.CLASS_NEEDS_INPUT -> NotificationPlan(
|
||||
channelId = CHANNEL_GATE,
|
||||
channelName = CHANNEL_GATE_NAME,
|
||||
highImportance = true,
|
||||
title = GATE_TITLE,
|
||||
text = GATE_TEXT,
|
||||
actions = listOf(Action.ALLOW, Action.DENY),
|
||||
)
|
||||
PushPayload.CLASS_DONE -> NotificationPlan(
|
||||
channelId = CHANNEL_DONE,
|
||||
channelName = CHANNEL_DONE_NAME,
|
||||
highImportance = false,
|
||||
title = DONE_TITLE,
|
||||
text = DONE_TEXT,
|
||||
actions = emptyList(),
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
|
||||
// ── Android assembly ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Turn a [payload] + its [plan] into a real [Notification]. Actions are attached only when the
|
||||
* plan has them AND the payload actually carries a token (a gate with no token degrades to a
|
||||
* tap-to-open notification). Every [PendingIntent] is `FLAG_IMMUTABLE`.
|
||||
*/
|
||||
public fun build(context: Context, payload: PushPayload, plan: NotificationPlan): Notification {
|
||||
ensureChannel(context, plan)
|
||||
val notificationId = notificationIdFor(payload.sessionId.toString())
|
||||
|
||||
val builder = NotificationCompat.Builder(context, plan.channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentTitle(plan.title)
|
||||
.setContentText(plan.text)
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setPriority(
|
||||
if (plan.highImportance) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT,
|
||||
)
|
||||
.setContentIntent(openAppIntent(context, notificationId))
|
||||
|
||||
if (plan.actions.isNotEmpty() && payload.isActionableGate) {
|
||||
val token = payload.token!!
|
||||
for (action in plan.actions) {
|
||||
builder.addAction(buildAction(context, action, payload.sessionId.toString(), token, notificationId))
|
||||
}
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/** Stable per-session id so a newer signal replaces the older card instead of stacking. */
|
||||
public fun notificationIdFor(sessionId: String): Int = sessionId.hashCode()
|
||||
|
||||
private fun buildAction(
|
||||
context: Context,
|
||||
action: Action,
|
||||
sessionId: String,
|
||||
token: String,
|
||||
notificationId: Int,
|
||||
): NotificationCompat.Action {
|
||||
val (label, pendingIntent) = when (action) {
|
||||
Action.DENY -> DENY_LABEL to denyIntent(context, sessionId, token, notificationId)
|
||||
Action.ALLOW -> ALLOW_LABEL to allowIntent(context, sessionId, token, notificationId)
|
||||
}
|
||||
return NotificationCompat.Action.Builder(0, label, pendingIntent).build()
|
||||
}
|
||||
|
||||
/** Deny → the auth-free [DenyBroadcastReceiver] (fail-safe, expedited, no UI). */
|
||||
private fun denyIntent(context: Context, sessionId: String, token: String, notificationId: Int): PendingIntent {
|
||||
val intent = Intent(context, DenyBroadcastReceiver::class.java).apply {
|
||||
putExtra(EXTRA_SESSION_ID, sessionId)
|
||||
putExtra(EXTRA_TOKEN, token)
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, requestCode(notificationId, action = 2), intent, immutableFlags())
|
||||
}
|
||||
|
||||
/** Allow → the [AllowTrampolineActivity] (hosts BiometricPrompt — a receiver cannot, R1). */
|
||||
private fun allowIntent(context: Context, sessionId: String, token: String, notificationId: Int): PendingIntent {
|
||||
val intent = Intent(context, AllowTrampolineActivity::class.java).apply {
|
||||
putExtra(EXTRA_SESSION_ID, sessionId)
|
||||
putExtra(EXTRA_TOKEN, token)
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
return PendingIntent.getActivity(context, requestCode(notificationId, action = 1), intent, immutableFlags())
|
||||
}
|
||||
|
||||
/** Tap-the-body → open the app. Carries NO token (opening the app never needs it). */
|
||||
private fun openAppIntent(context: Context, notificationId: Int): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
return PendingIntent.getActivity(context, requestCode(notificationId, action = 0), intent, immutableFlags())
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context, plan: NotificationPlan) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val importance =
|
||||
if (plan.highImportance) NotificationManager.IMPORTANCE_HIGH else NotificationManager.IMPORTANCE_DEFAULT
|
||||
manager.createNotificationChannel(NotificationChannel(plan.channelId, plan.channelName, importance))
|
||||
}
|
||||
|
||||
private fun immutableFlags(): Int = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
|
||||
/** Distinct request codes per (notification, action) so Allow/Deny/tap never collide or overwrite. */
|
||||
private fun requestCode(notificationId: Int, action: Int): Int = notificationId * REQUEST_CODE_STRIDE + action
|
||||
|
||||
// ── Intent extra keys (shared with the receiver + trampoline; single source of truth) ────────
|
||||
public const val EXTRA_SESSION_ID: String = "wang.yaojia.webterm.push.SESSION_ID"
|
||||
public const val EXTRA_TOKEN: String = "wang.yaojia.webterm.push.TOKEN"
|
||||
public const val EXTRA_NOTIFICATION_ID: String = "wang.yaojia.webterm.push.NOTIFICATION_ID"
|
||||
|
||||
// ── Channels + visible copy (static; token-free by construction) ─────────────────────────────
|
||||
public const val CHANNEL_GATE: String = "webterm.gate"
|
||||
public const val CHANNEL_DONE: String = "webterm.done"
|
||||
private const val CHANNEL_GATE_NAME = "Approval requests"
|
||||
private const val CHANNEL_DONE_NAME = "Session updates"
|
||||
private const val GATE_TITLE = "Claude needs your approval"
|
||||
private const val GATE_TEXT = "A pending action is waiting. Allow or deny it."
|
||||
private const val DONE_TITLE = "Claude finished"
|
||||
private const val DONE_TEXT = "Your session is done."
|
||||
private const val ALLOW_LABEL = "Allow"
|
||||
private const val DENY_LABEL = "Deny"
|
||||
|
||||
private const val REQUEST_CODE_STRIDE = 3
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Drives [PushRegistrar] from the app's lifecycle edges (plan §5 A31, §4.5): it resolves the CURRENT
|
||||
* FCM registration token from Firebase and (re)registers it with the hosts that need it.
|
||||
*
|
||||
* - [registerAllHosts] — **app start**: POST the current token to every paired host.
|
||||
* - [registerHost] — a **newly paired host**: POST the current token to just that host.
|
||||
*
|
||||
* Token rotation self-heals separately through [PushRegistrarTokenSink] (`onNewToken`), so this only
|
||||
* covers the "app came up" / "host added" edges. The Firebase token fetch is wrapped in `runCatching`
|
||||
* so a missing `google-services.json` / uninitialised `FirebaseApp` degrades to a no-op instead of
|
||||
* crashing (best-effort push per R1). The token is handed straight to the registrar and never logged.
|
||||
*/
|
||||
@Singleton
|
||||
public class PushCoordinator @Inject constructor(
|
||||
private val registrar: PushRegistrar,
|
||||
) {
|
||||
/** App-lifetime scope for the fire-and-forget best-effort fan-out. */
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
/** App start: register the current token with every paired host. Best-effort; safe to call repeatedly. */
|
||||
public fun registerAllHosts() {
|
||||
withCurrentToken { token -> scope.launch { registrar.registerAllHosts(token) } }
|
||||
}
|
||||
|
||||
/** A newly paired [host]: register the current token with just that host. */
|
||||
public fun registerHost(host: Host) {
|
||||
withCurrentToken { token -> scope.launch { registrar.registerHost(host, token) } }
|
||||
}
|
||||
|
||||
/** Resolve the current FCM token, then invoke [use]. A Firebase fault degrades to a no-op. */
|
||||
private fun withCurrentToken(use: (String) -> Unit) {
|
||||
runCatching {
|
||||
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
|
||||
if (!token.isNullOrEmpty()) use(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wiring.ApiClientFactory
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The single, shared decision-POST path used by BOTH the Allow trampoline (after biometric auth)
|
||||
* and the Deny receiver. Extracted so the token discipline lives in exactly one place (DRY) and is
|
||||
* JVM-unit-testable behind seams — no `FirebaseMessagingService`/`BiometricPrompt` needed.
|
||||
*
|
||||
* ### Which host?
|
||||
* The push payload carries NO host (content minimization, plan §4.5). The same FCM registration is
|
||||
* shared across every paired host (A31 registers the one device token per host), so on arrival we
|
||||
* cannot know which host issued the token. We therefore try each paired host in turn and stop at the
|
||||
* first that ACCEPTS (`204`). A host that did not issue this single-use token replies `403`
|
||||
* ([ApiClientError.DecisionRejected]) and we move on — safe because the token is host-scoped and
|
||||
* single-use, so a foreign host cannot act on it.
|
||||
*
|
||||
* ### Token discipline (plan §8, non-negotiable)
|
||||
* [token] is passed ONLY to [ApiClient.hookDecision]. It is NEVER persisted and NEVER logged: the
|
||||
* [diagnostics] seam receives outcomes and error *types* only, never the token (nor sessionId). A
|
||||
* retried-after-success POST returns `403` (server idempotency), which this treats as "not accepted"
|
||||
* and never crashes on.
|
||||
*/
|
||||
public class PushDecisionSubmitter(
|
||||
private val loadEndpoints: suspend () -> List<HostEndpoint>,
|
||||
private val clientFor: (HostEndpoint) -> ApiClient,
|
||||
private val diagnostics: (String) -> Unit = {},
|
||||
) {
|
||||
/**
|
||||
* POST `{ sessionId, decision, token }` to the issuing host. Returns true iff a host accepted.
|
||||
* Never throws — every failure (no host, transport error, 403/429) is swallowed to a `false`
|
||||
* so the fail-safe Deny path and the fire-and-forget Allow path stay robust.
|
||||
*/
|
||||
public suspend fun submit(sessionId: UUID, decision: HookDecision, token: String): Boolean {
|
||||
val endpoints = runCatching { loadEndpoints() }.getOrElse {
|
||||
diagnostics("decision(${decision.wire}): host lookup failed (${it.javaClass.simpleName})")
|
||||
return false
|
||||
}
|
||||
if (endpoints.isEmpty()) {
|
||||
diagnostics("decision(${decision.wire}): no paired host to post to")
|
||||
return false
|
||||
}
|
||||
for (endpoint in endpoints) {
|
||||
if (postToOne(endpoint, sessionId, decision, token)) return true
|
||||
}
|
||||
diagnostics("decision(${decision.wire}): no host accepted")
|
||||
return false
|
||||
}
|
||||
|
||||
/** One host attempt. Returns true only on `204`; classifies every other outcome without the token. */
|
||||
private suspend fun postToOne(
|
||||
endpoint: HostEndpoint,
|
||||
sessionId: UUID,
|
||||
decision: HookDecision,
|
||||
token: String,
|
||||
): Boolean =
|
||||
try {
|
||||
clientFor(endpoint).hookDecision(sessionId, decision, token)
|
||||
diagnostics("decision(${decision.wire}): accepted")
|
||||
true
|
||||
} catch (_: ApiClientError.DecisionRejected) {
|
||||
// Wrong host, or a stale/used token (idempotency). Not this host — try the next.
|
||||
diagnostics("decision(${decision.wire}): rejected by a host")
|
||||
false
|
||||
} catch (_: ApiClientError.RateLimited) {
|
||||
diagnostics("decision(${decision.wire}): rate-limited")
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
// Transport / unexpected status. Best-effort: log the TYPE only, move on.
|
||||
diagnostics("decision(${decision.wire}): error (${e.javaClass.simpleName})")
|
||||
false
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/** Wire the seams from the Hilt graph (used by the Deny receiver and Allow activity). */
|
||||
public fun fromRegistry(
|
||||
hostStore: HostStore,
|
||||
apiClientFactory: ApiClientFactory,
|
||||
diagnostics: (String) -> Unit = {},
|
||||
): PushDecisionSubmitter =
|
||||
PushDecisionSubmitter(
|
||||
loadEndpoints = { hostStore.loadAll().map { it.endpoint } },
|
||||
clientFor = { endpoint -> apiClientFactory.create(endpoint) },
|
||||
diagnostics = diagnostics,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The DATA-ONLY FCM payload the server sends (plan §4.5). It carries ONLY three fields:
|
||||
*
|
||||
* - [sessionId] — which session the signal is about,
|
||||
* - [cls] — the notify class (`"needs-input"` = a held gate, `"done"` = finished),
|
||||
* - [token] — the single-use `/hook/decision` capability token, present for **needs-input only**.
|
||||
*
|
||||
* It NEVER carries cwd / command / terminal bytes (content minimization, plan §4.5 / §8). The raw
|
||||
* `Map<String,String>` from `RemoteMessage.getData()` is untrusted external input, so [parse]
|
||||
* validates at the boundary and returns null on anything malformed rather than trusting it.
|
||||
*
|
||||
* Pure Kotlin (no Android imports) so the parse boundary is JVM-unit-testable.
|
||||
*/
|
||||
public data class PushPayload(
|
||||
public val sessionId: UUID,
|
||||
public val cls: String,
|
||||
public val token: String?,
|
||||
) {
|
||||
/** True only for a gate signal that actually carries a usable capability token. */
|
||||
public val isActionableGate: Boolean
|
||||
get() = cls == CLASS_NEEDS_INPUT && !token.isNullOrEmpty()
|
||||
|
||||
public companion object {
|
||||
public const val KEY_SESSION_ID: String = "sessionId"
|
||||
public const val KEY_CLS: String = "cls"
|
||||
public const val KEY_TOKEN: String = "token"
|
||||
|
||||
/** A held gate awaiting Allow/Deny — carries the single-use token. */
|
||||
public const val CLASS_NEEDS_INPUT: String = "needs-input"
|
||||
|
||||
/** An informational "session finished" signal — no token, no actions. */
|
||||
public const val CLASS_DONE: String = "done"
|
||||
|
||||
/**
|
||||
* Boundary validation. Returns null when [sessionId] is absent or not a UUID, or when
|
||||
* [cls] is absent/blank. [token] stays optional (present only for `needs-input`); it is
|
||||
* kept verbatim and NEVER logged — see [PushDecisionSubmitter].
|
||||
*/
|
||||
public fun parse(data: Map<String, String>): PushPayload? {
|
||||
val rawId = data[KEY_SESSION_ID]?.trim().orEmptyToNull() ?: return null
|
||||
val sessionId = runCatching { UUID.fromString(rawId) }.getOrNull() ?: return null
|
||||
val cls = data[KEY_CLS]?.trim().orEmptyToNull() ?: return null
|
||||
val token = data[KEY_TOKEN]?.orEmptyToNull()
|
||||
return PushPayload(sessionId = sessionId, cls = cls, token = token)
|
||||
}
|
||||
|
||||
private fun String?.orEmptyToNull(): String? = this?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.wiring.ApiClientFactory
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* The narrow per-host push-registration surface [PushRegistrar] drives (plan §5 A31, §4.5). Backed
|
||||
* in production by [ApiClient] (`registerFcmToken`/`unregisterFcmToken` → `POST`/`DELETE
|
||||
* /push/fcm-token`, whose loose validator + `Origin` guard live server-side, A33); a hand fake stands
|
||||
* in for the JVM test. Keeping this a two-method seam — instead of coupling the registrar to
|
||||
* [ApiClient]'s construction (`HostEndpoint` + OkHttp) — is what lets the fan-out / self-heal logic be
|
||||
* unit-tested with no device or transport.
|
||||
*/
|
||||
public interface HostPushApi {
|
||||
/** `POST /push/fcm-token {token}` — idempotent upsert on the server (re-registering is safe). */
|
||||
public suspend fun register(token: String)
|
||||
|
||||
/** `DELETE /push/fcm-token {token}` — idempotent even for a token the host never knew. */
|
||||
public suspend fun unregister(token: String)
|
||||
}
|
||||
|
||||
/** Production [HostPushApi] over one host's [ApiClient]. The token is passed straight through to the
|
||||
* POST/DELETE and is NEVER logged or retained here (plan §8 push-token discipline). */
|
||||
public class ApiClientHostPushApi(private val api: ApiClient) : HostPushApi {
|
||||
override suspend fun register(token: String): Unit = api.registerFcmToken(token)
|
||||
|
||||
override suspend fun unregister(token: String): Unit = api.unregisterFcmToken(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers this device's FCM registration token with EVERY paired host so a walked-away phone is
|
||||
* reachable no matter which host is running the session that needs input (plan §5 A31, §4.5).
|
||||
*
|
||||
* ### Self-heal (the token must always be current on every host)
|
||||
* The token can rotate and the host set can change, so registration is re-driven on every relevant
|
||||
* edge — the caller (FcmService.onNewToken, the pairing flow, host-list removal, app start) invokes:
|
||||
* - [registerAllHosts] — **app start** and **token rotation** (re-POST the current token to all hosts);
|
||||
* - [registerHost] — a **newly paired host** (POST the current token to just that host);
|
||||
* - [unregisterHost] — a **removed host** (DELETE the token from just that host).
|
||||
*
|
||||
* The current token is supplied by the caller (from `FirebaseMessaging.getToken()` or the
|
||||
* `onNewToken` callback) rather than held here — the registrar stays a stateless, immutable
|
||||
* coordinator that re-reads [HostStore] on every pass (no cached host list to drift).
|
||||
*
|
||||
* ### Best-effort fan-out
|
||||
* One host being unreachable (a walked-away device, a host that is off) must NOT block registering the
|
||||
* others. Each host is attempted independently; a non-cancellation failure is routed to [onError]
|
||||
* (host + throwable, **never the token**) and the loop continues. `CancellationException` is always
|
||||
* rethrown so structured-concurrency cancellation is honoured.
|
||||
*
|
||||
* ### `POST_NOTIFICATIONS` (API 33+) — a dependency, not a gate here
|
||||
* Registration succeeds regardless of the runtime notification permission: the token is what makes the
|
||||
* device *reachable*; the permission only gates whether an arriving push can be *shown*. So the
|
||||
* runtime prompt (with rationale) lives in the UI (device-QA), and this class never blocks on it. The
|
||||
* `POST_NOTIFICATIONS` manifest permission is already declared.
|
||||
*
|
||||
* ### Token discipline
|
||||
* The token is passed only to the POST/DELETE call and is never logged or persisted here; [onError]
|
||||
* deliberately receives no token so an error sink can never leak it (plan §8).
|
||||
*/
|
||||
@Singleton
|
||||
public class PushRegistrar internal constructor(
|
||||
private val hosts: HostStore,
|
||||
private val apiFor: (Host) -> HostPushApi,
|
||||
private val onError: (Host, Throwable) -> Unit,
|
||||
) {
|
||||
/**
|
||||
* Production constructor: mints a per-host [ApiClient] over the shared transport ([ApiClientFactory])
|
||||
* for each paired host. Failures are swallowed at the registrar boundary by default (best-effort
|
||||
* fan-out); a caller that wants observability supplies its own [onError] via the internal ctor.
|
||||
*/
|
||||
@Inject
|
||||
public constructor(
|
||||
hosts: HostStore,
|
||||
apiClientFactory: ApiClientFactory,
|
||||
) : this(
|
||||
hosts = hosts,
|
||||
apiFor = { host -> ApiClientHostPushApi(apiClientFactory.create(host.endpoint)) },
|
||||
onError = { _, _ -> },
|
||||
)
|
||||
|
||||
/** POST the current [token] to EVERY paired host (app start + token rotation). Best-effort. */
|
||||
public suspend fun registerAllHosts(token: String) {
|
||||
for (host in hosts.loadAll()) {
|
||||
attempt(host) { register(token) }
|
||||
}
|
||||
}
|
||||
|
||||
/** POST the current [token] to a single newly-paired [host]. Best-effort. */
|
||||
public suspend fun registerHost(host: Host, token: String) {
|
||||
attempt(host) { register(token) }
|
||||
}
|
||||
|
||||
/** DELETE the [token] from a single [host] being removed. Best-effort. */
|
||||
public suspend fun unregisterHost(host: Host, token: String) {
|
||||
attempt(host) { unregister(token) }
|
||||
}
|
||||
|
||||
private suspend fun attempt(host: Host, call: suspend HostPushApi.() -> Unit) {
|
||||
try {
|
||||
apiFor(host).call()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
onError(host, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The A31 [PushTokenSink] implementation: a rotated FCM registration token (from
|
||||
* [FcmService.onNewToken]) is re-registered with EVERY paired host via [PushRegistrar] — the self-heal
|
||||
* path (plan §4.5). `@Binds` in `di/PushModule` publishes this as the concrete `PushTokenSink`, so the
|
||||
* `Optional<PushTokenSink>` [FcmService] injects becomes present.
|
||||
*
|
||||
* [onTokenRefreshed] is a synchronous fire-and-forget hook (the FCM callback is not `suspend`), so it
|
||||
* launches the best-effort fan-out on a long-lived app scope. [PushRegistrar.registerAllHosts] already
|
||||
* swallows per-host failures and re-reads [HostStore][wang.yaojia.webterm.hostregistry.HostStore] on
|
||||
* every pass, so nothing here retains the token or the host list.
|
||||
*/
|
||||
@Singleton
|
||||
public class PushRegistrarTokenSink @Inject constructor(
|
||||
private val registrar: PushRegistrar,
|
||||
) : PushTokenSink {
|
||||
|
||||
/** App-lifetime scope for the fire-and-forget re-registration (survives the FCM callback returning). */
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun onTokenRefreshed(token: String) {
|
||||
scope.launch { registrar.registerAllHosts(token) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import dagger.BindsOptionalOf
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
/**
|
||||
* The A31 seam. [FcmService.onNewToken] forwards a rotated FCM registration token here; the
|
||||
* implementation (A31 `PushRegistrar` — `POST /push/fcm-token` per host) is NOT part of A30.
|
||||
*
|
||||
* A30 only DECLARES this interface and consumes it optionally, so the FCM client compiles and runs
|
||||
* standalone before A31 lands. A31 supplies the behaviour with a single `@Binds PushTokenSink`.
|
||||
*/
|
||||
public interface PushTokenSink {
|
||||
/** A new/rotated device token is available — (re)register it with every paired host (A31). */
|
||||
public fun onTokenRefreshed(token: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes `Optional<PushTokenSink>` injectable even when NOTHING binds a [PushTokenSink] yet (A31 not
|
||||
* landed) — Hilt injects `Optional.empty()`. Once A31 adds `@Binds PushTokenSink`, the same injection
|
||||
* point sees the real sink. This is the clean "optional collaborator" pattern (no mutable global).
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
public interface PushSeamModule {
|
||||
@BindsOptionalOf
|
||||
public fun optionalPushTokenSink(): PushTokenSink
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import wang.yaojia.webterm.nav.LayoutMode
|
||||
import wang.yaojia.webterm.nav.LayoutPolicy
|
||||
import wang.yaojia.webterm.nav.WindowWidth
|
||||
|
||||
/**
|
||||
* # AdaptiveHome (A26) — the Material 3 Adaptive shell (iPad-equivalent split).
|
||||
*
|
||||
* The Android analogue of iOS's adaptive root: a `NavigationSuiteScaffold` (bottom-bar → nav-rail →
|
||||
* nav-drawer as the window grows) wrapping a layout that switches on [LayoutPolicy]:
|
||||
*
|
||||
* - [LayoutMode.LIST_DETAIL] (medium/expanded) → a `ListDetailPaneScaffold` showing the session
|
||||
* **list pane** and the terminal **detail pane** side by side (plan §"iPad-equivalent").
|
||||
* - [LayoutMode.STACK] (compact) → a single pane rendering the list; the outer `NavGraph` pushes the
|
||||
* terminal on top (the existing phone flow). Full inter-screen routing is a later app-assembly step
|
||||
* (A32), so this shell only exposes the list / detail **slots** — it does NOT hard-wire every screen.
|
||||
*
|
||||
* The size-class decision is read ONCE, here, through [LayoutPolicy] (mirroring iOS's single decision
|
||||
* point); the thin `WindowSizeClass → WindowWidth` extraction is [toWindowWidth]. Everything else — pane
|
||||
* rendering, navigation-suite chrome, animations — is device-QA (plan §7).
|
||||
*
|
||||
* @param listPane the session list / chooser (A20 `SessionListScreen`), shown in the list pane / stack.
|
||||
* @param detailPane the terminal / detail (A21 `TerminalScreen`, or an empty placeholder), shown in the
|
||||
* detail pane when [LayoutMode.LIST_DETAIL]. Ignored in [LayoutMode.STACK] (the NavGraph pushes it).
|
||||
* @param destinations the top-level navigation entries rendered by the navigation suite.
|
||||
* @param selectedDestination index of the active [destinations] entry.
|
||||
* @param onSelectDestination invoked when a navigation-suite entry is tapped.
|
||||
* @param windowSizeClass the current window size class; defaults to `currentWindowAdaptiveInfo()` and is
|
||||
* injectable so the shell can be driven from a preview / test harness.
|
||||
*/
|
||||
@Composable
|
||||
public fun AdaptiveHome(
|
||||
listPane: @Composable () -> Unit,
|
||||
detailPane: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
destinations: List<AdaptiveDestination> = DEFAULT_DESTINATIONS,
|
||||
selectedDestination: Int = 0,
|
||||
onSelectDestination: (Int) -> Unit = {},
|
||||
windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass,
|
||||
) {
|
||||
val mode = LayoutPolicy.mode(windowSizeClass.toWindowWidth())
|
||||
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
destinations.forEachIndexed { index, destination ->
|
||||
item(
|
||||
selected = index == selectedDestination,
|
||||
onClick = { onSelectDestination(index) },
|
||||
icon = { Text(destination.glyph) },
|
||||
label = { Text(destination.label) },
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) {
|
||||
when (mode) {
|
||||
LayoutMode.LIST_DETAIL -> ListDetailShell(listPane = listPane, detailPane = detailPane)
|
||||
// Compact: single pane. The NavGraph (A32) owns list→detail push, so the shell renders only
|
||||
// the list here — it must NOT hard-wire the terminal into the compact path.
|
||||
LayoutMode.STACK -> Box(modifier = Modifier.fillMaxSize()) { listPane() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The side-by-side pane composition. Uses the Material 3 Adaptive `ListDetailPaneScaffold` driven by a
|
||||
* remembered navigator, so pane visibility / expansion follows the adaptive directive. Each pane is an
|
||||
* `AnimatedPane` so show/hide transitions are stock.
|
||||
*/
|
||||
@Composable
|
||||
private fun ListDetailShell(
|
||||
listPane: @Composable () -> Unit,
|
||||
detailPane: @Composable () -> Unit,
|
||||
) {
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator()
|
||||
ListDetailPaneScaffold(
|
||||
navigator.scaffoldDirective,
|
||||
navigator.scaffoldValue,
|
||||
listPane = { AnimatedPane { listPane() } },
|
||||
detailPane = { AnimatedPane { detailPane() } },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
/** One top-level navigation-suite entry: a glyph + a label. Glyph is a plain string to avoid a
|
||||
* material-icons dependency (not in the `:app` Compose bundle — same rationale as the host menu). */
|
||||
public data class AdaptiveDestination(val label: String, val glyph: String)
|
||||
|
||||
/** The default navigation-suite entries (sessions / projects) — a thin, replaceable default. */
|
||||
public val DEFAULT_DESTINATIONS: List<AdaptiveDestination> = listOf(
|
||||
AdaptiveDestination(label = "会话", glyph = "▤"),
|
||||
AdaptiveDestination(label = "项目", glyph = "▦"),
|
||||
)
|
||||
|
||||
/**
|
||||
* The thin, Compose-side `WindowSizeClass → WindowWidth` extraction (kept OUT of the pure
|
||||
* [LayoutPolicy] so the decision stays JVM-testable). Uses the modern `isWidthAtLeastBreakpoint`
|
||||
* predicate rather than the deprecated `windowWidthSizeClass` accessor: EXPANDED (≥840dp) → medium
|
||||
* (≥600dp) → compact, matching the Material width breakpoints.
|
||||
*/
|
||||
private fun WindowSizeClass.toWindowWidth(): WindowWidth = when {
|
||||
isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> WindowWidth.EXPANDED
|
||||
isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> WindowWidth.MEDIUM
|
||||
else -> WindowWidth.COMPACT
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
import wang.yaojia.webterm.viewmodels.CertError
|
||||
import wang.yaojia.webterm.viewmodels.CertPhase
|
||||
import wang.yaojia.webterm.viewmodels.CertSummaryView
|
||||
import wang.yaojia.webterm.viewmodels.ClientCertUiState
|
||||
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
||||
|
||||
/**
|
||||
* # ClientCertScreen (A27) — device-certificate (mTLS) management.
|
||||
*
|
||||
* The Compose shell over [ClientCertViewModel], reached from the session-list host menu "设备证书"
|
||||
* (A20). It lets the user **import** a `.p12` device certificate (SAF `ACTION_OPEN_DOCUMENT` + a
|
||||
* passphrase field), **rotate** it, or **remove** it, and shows the installed identity's summary
|
||||
* (subject-CN / issuer-CN / expiry, with an **EXPIRED** warning). A **distinct screen**, not folded
|
||||
* into pairing (plan §1/§5 A27).
|
||||
*
|
||||
* ### Secrets & trust discipline (plan §8)
|
||||
* - The passphrase is entered in a masked field, handed straight to the ViewModel with the file bytes,
|
||||
* and cleared from UI state the instant the import/rotation is dispatched — it is never logged,
|
||||
* persisted, or echoed. The private key lands non-exportably in AndroidKeyStore inside the
|
||||
* repository (the "one key home"); this screen never sees it.
|
||||
* - A bad passphrase can NOT clobber a previously-installed cert: the import validates before
|
||||
* persisting, so a failure leaves the prior summary on screen and only surfaces an inert error.
|
||||
* - Every cert-derived string (CNs, expiry) is inert [Text] — no autolink / markdown / `AnnotatedString`.
|
||||
* Error copy is app-authored, never a server/exception string.
|
||||
*
|
||||
* The SAF picker + AndroidKeyStore/Tink I/O it drives are device-QA (plan §7); the state machine is
|
||||
* JVM-tested in `ClientCertViewModelTest`.
|
||||
*
|
||||
* @param viewModel the device-cert presenter (the nav layer builds it over the shared `IdentityRepository`).
|
||||
* @param onBack optional up-navigation back to the host menu.
|
||||
*/
|
||||
@Composable
|
||||
public fun ClientCertScreen(
|
||||
viewModel: ClientCertViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
// Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load.
|
||||
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
// Which action the pending SAF pick feeds: false = first import, true = rotate an existing cert.
|
||||
var pendingRotate by remember { mutableStateOf(false) }
|
||||
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
// Capture then immediately scrub the secret from UI state — the VM now owns it for the handoff.
|
||||
val secret = passphrase
|
||||
val rotate = pendingRotate
|
||||
passphrase = ""
|
||||
scope.launch {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
|
||||
} ?: ByteArray(0) // an unreadable/empty pick decode-fails → surfaces as an INVALID_FILE error
|
||||
if (rotate) viewModel.rotate(bytes, secret) else viewModel.import(bytes, secret)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.confirmingRemove) {
|
||||
RemoveConfirmDialog(onConfirm = viewModel::confirmRemove, onDismiss = viewModel::cancelRemove)
|
||||
}
|
||||
|
||||
ClientCertScreen(
|
||||
state = state,
|
||||
passphrase = passphrase,
|
||||
onPassphraseChange = { passphrase = it },
|
||||
onImport = { pendingRotate = false; picker.launch(P12_MIME_TYPES) },
|
||||
onRotate = { pendingRotate = true; picker.launch(P12_MIME_TYPES) },
|
||||
onRemove = viewModel::requestRemove,
|
||||
onDismissError = viewModel::clearError,
|
||||
modifier = modifier,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stateless body — pure inputs so a [Preview] and any host can drive it without the SAF/VM machinery. */
|
||||
@Composable
|
||||
public fun ClientCertScreen(
|
||||
state: ClientCertUiState,
|
||||
passphrase: String,
|
||||
onPassphraseChange: (String) -> Unit,
|
||||
onImport: () -> Unit,
|
||||
onRotate: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(Spacing.lg16),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.md12),
|
||||
) {
|
||||
Header(onBack = onBack)
|
||||
|
||||
when (state.phase) {
|
||||
CertPhase.LOADING -> LoadingRow()
|
||||
CertPhase.READY, CertPhase.BUSY -> {
|
||||
val busy = state.phase == CertPhase.BUSY
|
||||
val summary = state.summary
|
||||
if (summary != null) {
|
||||
InstalledCertCard(summary)
|
||||
} else {
|
||||
Text(
|
||||
text = "尚未安装设备证书。隧道主机(mTLS)需要先在此导入 .p12 证书。",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) }
|
||||
|
||||
PassphraseField(
|
||||
passphrase = passphrase,
|
||||
onPassphraseChange = onPassphraseChange,
|
||||
enabled = !busy,
|
||||
)
|
||||
CertActions(
|
||||
hasCert = summary != null,
|
||||
enabled = !busy,
|
||||
onImport = onImport,
|
||||
onRotate = onRotate,
|
||||
onRemove = onRemove,
|
||||
)
|
||||
if (busy) LoadingRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(onBack: (() -> Unit)?) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
if (onBack != null) {
|
||||
TextButton(onClick = onBack) { Text("返回") }
|
||||
}
|
||||
Text(text = "设备证书", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
|
||||
/** The installed identity summary: subject/issuer CN, expiry, and a prominent EXPIRED warning. */
|
||||
@Composable
|
||||
private fun InstalledCertCard(summary: CertSummaryView) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Spacing.lg16),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
SummaryRow(label = "设备(CN)", value = summary.subjectCommonName)
|
||||
SummaryRow(label = "签发方(CN)", value = summary.issuerCommonName)
|
||||
SummaryRow(label = "到期", value = summary.expiry)
|
||||
if (summary.isExpired) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Text(
|
||||
text = "⚠ 证书已过期,无法用于连接。请更换一份有效证书。",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = WebTermColors.statusStuck,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Cert-derived value: inert monospaced Text — no linkify/markdown (plan §8).
|
||||
Text(text = value, style = WebTermType.monoTabular(13), color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorCard(error: CertError, onDismiss: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Spacing.md12),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
Text(
|
||||
text = errorCopy(error),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("知道了") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassphraseField(passphrase: String, onPassphraseChange: (String) -> Unit, enabled: Boolean) {
|
||||
OutlinedTextField(
|
||||
value = passphrase,
|
||||
onValueChange = onPassphraseChange,
|
||||
label = { Text("证书口令(.p12 密码)") },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CertActions(
|
||||
hasCert: Boolean,
|
||||
enabled: Boolean,
|
||||
onImport: () -> Unit,
|
||||
onRotate: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
if (hasCert) {
|
||||
Button(onClick = onRotate, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("更换证书…")
|
||||
}
|
||||
OutlinedButton(onClick = onRemove, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("移除证书")
|
||||
}
|
||||
} else {
|
||||
Button(onClick = onImport, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("导入设备证书…")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoveConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("移除设备证书?") },
|
||||
text = { Text("移除后,需要 mTLS 的隧道主机将无法连接,直到你重新导入证书。") },
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text("移除") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingRow() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps the coarse [CertError] to inert, app-authored copy (never an exception/server string, §8). */
|
||||
private fun errorCopy(error: CertError): String = when (error) {
|
||||
CertError.BAD_PASSPHRASE -> "证书口令不正确。原有证书保持不变,请检查口令后重试。"
|
||||
CertError.INVALID_FILE -> "无法读取该文件:它不是有效的 .p12 证书。"
|
||||
CertError.NO_IDENTITY -> "该 .p12 里没有可用的私钥(可能只含证书)。请选择包含私钥的证书文件。"
|
||||
CertError.IMPORT_FAILED -> "导入证书失败。请重试。"
|
||||
CertError.REMOVE_FAILED -> "移除证书失败。请重试。"
|
||||
}
|
||||
|
||||
/** SAF filter for PKCS#12 material; some providers tag `.p12`/`.pfx` as octet-stream, so include it. */
|
||||
private val P12_MIME_TYPES = arrayOf(
|
||||
"application/x-pkcs12",
|
||||
"application/pkcs12",
|
||||
"application/octet-stream",
|
||||
)
|
||||
|
||||
@Preview(name = "ClientCertScreen — installed")
|
||||
@Composable
|
||||
private fun ClientCertScreenInstalledPreview() {
|
||||
WebTermTheme {
|
||||
ClientCertScreen(
|
||||
state = ClientCertUiState(
|
||||
summary = CertSummaryView(
|
||||
subjectCommonName = "t1-android",
|
||||
issuerCommonName = "webterm-device-ca",
|
||||
expiry = "2027年6月15日",
|
||||
isExpired = false,
|
||||
),
|
||||
phase = CertPhase.READY,
|
||||
),
|
||||
passphrase = "",
|
||||
onPassphraseChange = {},
|
||||
onImport = {},
|
||||
onRotate = {},
|
||||
onRemove = {},
|
||||
onDismissError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "ClientCertScreen — expired + none")
|
||||
@Composable
|
||||
private fun ClientCertScreenExpiredPreview() {
|
||||
WebTermTheme {
|
||||
ClientCertScreen(
|
||||
state = ClientCertUiState(
|
||||
summary = CertSummaryView("t1-android", "webterm-device-ca", "2020年1月1日", isExpired = true),
|
||||
phase = CertPhase.READY,
|
||||
error = CertError.BAD_PASSPHRASE,
|
||||
),
|
||||
passphrase = "",
|
||||
onPassphraseChange = {},
|
||||
onImport = {},
|
||||
onRotate = {},
|
||||
onRemove = {},
|
||||
onDismissError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.Stroke
|
||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
import wang.yaojia.webterm.viewmodels.DiffBinaryRow
|
||||
import wang.yaojia.webterm.viewmodels.DiffFileHeaderRow
|
||||
import wang.yaojia.webterm.viewmodels.DiffHunkHeaderRow
|
||||
import wang.yaojia.webterm.viewmodels.DiffLineKind
|
||||
import wang.yaojia.webterm.viewmodels.DiffLineRow
|
||||
import wang.yaojia.webterm.viewmodels.DiffPhase
|
||||
import wang.yaojia.webterm.viewmodels.DiffRow
|
||||
import wang.yaojia.webterm.viewmodels.DiffUiState
|
||||
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
||||
|
||||
/**
|
||||
* # DiffScreen (A24) — the read-only staged/unstaged git-diff viewer.
|
||||
*
|
||||
* Renders the presenter's flattened files→hunks→lines list in a `LazyColumn`, with a Working/Staged
|
||||
* toggle in the header. Every server-derived string (paths, hunk headers, code lines) is rendered as
|
||||
* **inert monospaced [Text]** — plain `Text`, never `ClickableText`/`LinkAnnotation`/autolink/markdown
|
||||
* — so a hostile diff cannot inject a tappable link or markup (plan §8). Line kinds carry the A13
|
||||
* colour tokens (added → green, removed → red). Layout/interaction is device-QA (plan §7).
|
||||
*/
|
||||
@Composable
|
||||
public fun DiffScreen(
|
||||
state: DiffUiState,
|
||||
onSelectStaged: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onRefresh: () -> Unit = {},
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
DiffHeader(staged = state.staged, onSelectStaged = onSelectStaged, onBack = onBack)
|
||||
if (state.truncated) {
|
||||
DiffNotice("Diff truncated — too large to display fully.")
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (state.phase) {
|
||||
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
|
||||
DiffPhase.EMPTY -> CenteredMessage("No changes")
|
||||
DiffPhase.ERROR -> DiffError(onRetry = onRefresh)
|
||||
DiffPhase.LOADED -> DiffList(rows = state.rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful entry point: binds [viewModel] to a lifecycle scope, collects its state, and wires the
|
||||
* toggle/refresh callbacks. The nav layer supplies the already-constructed presenter (host + path).
|
||||
*/
|
||||
@Composable
|
||||
public fun DiffScreen(
|
||||
viewModel: DiffViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
// Bind to the LaunchedEffect scope (cancelled when this screen leaves composition), then load.
|
||||
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||
DiffScreen(
|
||||
state = state,
|
||||
onSelectStaged = viewModel::selectStaged,
|
||||
modifier = modifier,
|
||||
onRefresh = viewModel::refresh,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffHeader(
|
||||
staged: Boolean,
|
||||
onSelectStaged: (Boolean) -> Unit,
|
||||
onBack: (() -> Unit)?,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
if (onBack != null) {
|
||||
TextButton(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
Text(
|
||||
text = "Diff",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(Spacing.sm8))
|
||||
FilterChip(
|
||||
selected = !staged,
|
||||
onClick = { onSelectStaged(false) },
|
||||
label = { Text("Working") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = staged,
|
||||
onClick = { onSelectStaged(true) },
|
||||
label = { Text("Staged") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffList(rows: List<DiffRow>) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(items = rows, key = { it.id }) { row ->
|
||||
when (row) {
|
||||
is DiffFileHeaderRow -> FileHeader(row)
|
||||
is DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary)
|
||||
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
|
||||
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileHeader(row: DiffFileHeaderRow) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = row.path,
|
||||
style = WebTermType.monoTabular(13),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
|
||||
Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck)
|
||||
}
|
||||
}
|
||||
|
||||
/** One inert monospaced diff line. No linkify/markdown — a hostile diff renders as literal text (§8). */
|
||||
@Composable
|
||||
private fun DiffText(text: String, color: Color) {
|
||||
Text(
|
||||
text = text,
|
||||
style = WebTermType.monoTabular(13),
|
||||
color = color,
|
||||
softWrap = false,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Clip,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffError(onRetry: () -> Unit) {
|
||||
CenteredContent {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Failed to load diff.", color = MaterialTheme.colorScheme.onBackground)
|
||||
TextButton(onClick = onRetry) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffNotice(message: String) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(message: String) {
|
||||
CenteredContent {
|
||||
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredContent(content: @Composable () -> Unit) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() }
|
||||
}
|
||||
|
||||
/** A13 colour token per line kind (added → green, removed → red; context/hunk/meta from the scheme). */
|
||||
@Composable
|
||||
private fun lineColor(kind: DiffLineKind): Color = when (kind) {
|
||||
DiffLineKind.ADDED -> WebTermColors.statusWorking
|
||||
DiffLineKind.REMOVED -> WebTermColors.statusStuck
|
||||
DiffLineKind.META -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
DiffLineKind.HUNK -> MaterialTheme.colorScheme.primary
|
||||
DiffLineKind.CONTEXT -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
/** The gutter marker the server stripped off; re-added purely for readability (kept 1-col wide). */
|
||||
private fun markerFor(kind: DiffLineKind): String = when (kind) {
|
||||
DiffLineKind.ADDED -> "+ "
|
||||
DiffLineKind.REMOVED -> "- "
|
||||
else -> " "
|
||||
}
|
||||
|
||||
@Preview(name = "DiffScreen — loaded")
|
||||
@Composable
|
||||
private fun DiffScreenPreview() {
|
||||
val rows = listOf<DiffRow>(
|
||||
DiffFileHeaderRow(0, "src/app/Main.kt", "modified", added = 2, removed = 1),
|
||||
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
|
||||
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
|
||||
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
|
||||
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
|
||||
DiffLineRow(5, DiffLineKind.ADDED, " println(\"added\")"),
|
||||
DiffLineRow(6, DiffLineKind.CONTEXT, "}"),
|
||||
)
|
||||
WebTermTheme {
|
||||
DiffScreen(
|
||||
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true),
|
||||
onSelectStaged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.ExperimentalGetImage
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.view.LifecycleCameraController
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.viewmodels.EntryError
|
||||
import wang.yaojia.webterm.viewmodels.PairingUiState
|
||||
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
||||
import wang.yaojia.webterm.viewmodels.WarningTier
|
||||
import wang.yaojia.webterm.viewmodels.needsExplicitAck
|
||||
|
||||
/**
|
||||
* # PairingScreen (A19) — QR scan / manual URL entry → confirm-before-network → probe → warning tiers.
|
||||
*
|
||||
* The Compose shell over [PairingViewModel]. Two ways in — a **CameraX + ML Kit** QR analyzer or a
|
||||
* **manual URL field** — both funnel their string through the ViewModel's single
|
||||
* [HostEndpoint.fromBaseUrl][wang.yaojia.webterm.wire.HostEndpoint] validator; NEITHER auto-connects
|
||||
* (confirm-before-network, plan §5). The confirm card renders the §5.4 warning tier; a public host
|
||||
* requires an explicit acknowledge, and a tunnel host routes to the device-cert screen when the probe is
|
||||
* cert-gated. Every displayed URL/host is INERT [Text] (no autolink/markdown, plan §8).
|
||||
*
|
||||
* ### Permissions
|
||||
* - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to
|
||||
* manual entry (the QR path is never mandatory).
|
||||
* - **POST_NOTIFICATIONS** is NOT requested here — push registration owns that prompt (A31); pairing
|
||||
* only establishes the host.
|
||||
*
|
||||
* All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is
|
||||
* JVM-tested in `PairingViewModelTest`.
|
||||
*
|
||||
* @param viewModel the pairing state machine (nav layer builds it with the real [pairingProber]).
|
||||
* @param onPaired invoked once with the persisted [Host] after a successful pair.
|
||||
* @param onImportCert opens the device-cert screen (A27) when a tunnel host is cert-gated.
|
||||
*/
|
||||
@Composable
|
||||
public fun PairingScreen(
|
||||
viewModel: PairingViewModel,
|
||||
onPaired: (Host) -> Unit,
|
||||
onImportCert: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(Spacing.lg16),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.md12),
|
||||
) {
|
||||
Text(text = "配对主机", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
when (val s = state) {
|
||||
is PairingUiState.Entry -> EntryStep(
|
||||
error = s.error,
|
||||
onManual = viewModel::onManualEntry,
|
||||
onScanned = viewModel::onQrScanned,
|
||||
)
|
||||
is PairingUiState.Confirming -> ConfirmStep(
|
||||
state = s,
|
||||
onName = viewModel::setName,
|
||||
onConfirm = viewModel::confirm,
|
||||
onCancel = viewModel::reset,
|
||||
)
|
||||
is PairingUiState.Probing -> ProbingStep(host = s.name)
|
||||
is PairingUiState.CertRequired -> CertRequiredStep(
|
||||
host = s.name,
|
||||
onImportCert = onImportCert,
|
||||
onRetry = { viewModel.retry() },
|
||||
onCancel = viewModel::reset,
|
||||
)
|
||||
is PairingUiState.Failed -> FailedStep(
|
||||
message = s.message,
|
||||
needsAck = s.tier.needsExplicitAck,
|
||||
onRetry = { ack -> viewModel.retry(acknowledgedPublicRisk = ack) },
|
||||
onCancel = viewModel::reset,
|
||||
)
|
||||
is PairingUiState.Paired -> LaunchedEffect(s.host) { onPaired(s.host) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entry: manual URL or QR scan ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun EntryStep(
|
||||
error: EntryError?,
|
||||
onManual: (String) -> Unit,
|
||||
onScanned: (String) -> Unit,
|
||||
) {
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
var url by remember { mutableStateOf("") }
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
TextButton(onClick = { scanning = false }) { Text(if (scanning) "手动输入" else "● 手动输入") }
|
||||
TextButton(onClick = { scanning = true }) { Text(if (scanning) "● 扫码" else "扫码") }
|
||||
}
|
||||
|
||||
if (scanning) {
|
||||
QrScanner(onScanned = onScanned)
|
||||
Text("将服务器分享二维码对准取景框。", style = MaterialTheme.typography.bodySmall)
|
||||
return
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text("主机地址(http(s)://…)") },
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (error == EntryError.INVALID_URL) {
|
||||
Text(
|
||||
text = "请输入有效的 http(s):// 地址。",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { onManual(url) },
|
||||
enabled = url.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("下一步") }
|
||||
}
|
||||
|
||||
/**
|
||||
* The CameraX + ML Kit QR analyzer, gated on the CAMERA runtime permission. A denial shows a rationale
|
||||
* + re-request; the caller always offers the manual path so QR is never mandatory.
|
||||
*/
|
||||
@Composable
|
||||
private fun QrScanner(onScanned: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var granted by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
granted = it
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (!granted) launcher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
|
||||
if (granted) {
|
||||
CameraPreview(onScanned = onScanned)
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
Text("需要相机权限才能扫描配对二维码。你也可以改用手动输入。")
|
||||
Button(onClick = { launcher.launch(Manifest.permission.CAMERA) }) { Text("授予相机权限") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraPreview(onScanned: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val controller = remember { LifecycleCameraController(context) }
|
||||
|
||||
DisposableEffect(controller, lifecycleOwner) {
|
||||
controller.setImageAnalysisAnalyzer(
|
||||
ContextCompat.getMainExecutor(context),
|
||||
QrCodeAnalyzer(onScanned),
|
||||
)
|
||||
controller.bindToLifecycle(lifecycleOwner)
|
||||
onDispose { controller.unbind() }
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
factory = { ctx -> PreviewView(ctx).also { it.controller = controller } },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Confirm: warning tier + name + (public) acknowledge ──────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ConfirmStep(
|
||||
state: PairingUiState.Confirming,
|
||||
onName: (String) -> Unit,
|
||||
onConfirm: (Boolean) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var acknowledged by remember(state.endpoint) { mutableStateOf(false) }
|
||||
|
||||
TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged)
|
||||
// The dialed URL is validated but user/QR-sourced — render INERT (plan §8).
|
||||
Text(
|
||||
text = state.endpoint.baseUrl,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = onName,
|
||||
label = { Text("名称") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (state.tier.needsExplicitAck) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
|
||||
Text("我了解这是公网地址的风险,仍要连接。")
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
|
||||
Button(
|
||||
onClick = { onConfirm(acknowledged) },
|
||||
enabled = !state.tier.needsExplicitAck || acknowledged,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("连接") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TierWarningCard(tier: WarningTier, highlight: Boolean) {
|
||||
val (title, body) = tierCopy(tier)
|
||||
val container =
|
||||
if (highlight || tier == WarningTier.PUBLIC) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
Card(colors = CardDefaults.cardColors(containerColor = container), shape = RoundedCornerShape(Spacing.md12)) {
|
||||
Column(modifier = Modifier.padding(Spacing.md12), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleSmall)
|
||||
Text(text = body, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tierCopy(tier: WarningTier): Pair<String, String> = when (tier) {
|
||||
WarningTier.LOOPBACK -> "本机地址" to "指向本机,安全。"
|
||||
WarningTier.PRIVATE_LAN -> "局域网地址" to "同一局域网内以 ws:// 明文传输(未加密)。"
|
||||
WarningTier.TAILSCALE -> "Tailscale 地址" to "流量由 WireGuard 端到端加密。"
|
||||
WarningTier.TUNNEL -> "隧道主机" to "需要已安装的设备证书(mTLS)才能连接。"
|
||||
WarningTier.PUBLIC -> "公网地址(高风险)" to "任何能访问该地址的人都可能连到你的终端。请确认这是你信任的主机。"
|
||||
}
|
||||
|
||||
// ── Probing / cert-required / failed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ProbingStep(host: String) {
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
CircularProgressIndicator()
|
||||
Text("正在验证 $host …", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CertRequiredStep(
|
||||
host: String,
|
||||
onImportCert: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
shape = RoundedCornerShape(Spacing.md12),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(Spacing.md12), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
Text("需要设备证书", style = MaterialTheme.typography.titleSmall)
|
||||
Text("$host 是隧道主机,必须先导入设备证书(mTLS)才能配对。", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
|
||||
Button(onClick = onImportCert, modifier = Modifier.weight(1f)) { Text("导入证书") }
|
||||
}
|
||||
TextButton(onClick = onRetry, modifier = Modifier.fillMaxWidth()) { Text("已导入,重试") }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailedStep(
|
||||
message: String,
|
||||
needsAck: Boolean,
|
||||
onRetry: (Boolean) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
shape = RoundedCornerShape(Spacing.md12),
|
||||
) {
|
||||
// Inert copy (app-authored, never a raw server string — plan §8).
|
||||
Text(text = message, modifier = Modifier.padding(Spacing.md12), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("返回") }
|
||||
// A prior public-host ack was consumed reaching the probe; carry it through the retry.
|
||||
Button(onClick = { onRetry(needsAck) }, modifier = Modifier.weight(1f)) { Text("重试") }
|
||||
}
|
||||
}
|
||||
|
||||
// ── ML Kit QR analyzer ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extracts the first QR raw value from a CameraX frame and reports it ONCE. `@ExperimentalGetImage` is
|
||||
* required to read the backing `android.media.Image`; the analyzer always closes the [ImageProxy] so the
|
||||
* pipeline never stalls.
|
||||
*/
|
||||
@ExperimentalGetImage
|
||||
private class QrCodeAnalyzer(private val onQr: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
private val scanner = BarcodeScanning.getClient(
|
||||
BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build(),
|
||||
)
|
||||
private var reported = false
|
||||
|
||||
override fun analyze(imageProxy: ImageProxy) {
|
||||
val media = imageProxy.image
|
||||
if (media == null || reported) {
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
val input = InputImage.fromMediaImage(media, imageProxy.imageInfo.rotationDegrees)
|
||||
scanner.process(input)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
barcodes.firstNotNullOfOrNull { it.rawValue }?.let { value ->
|
||||
if (!reported) {
|
||||
reported = true
|
||||
onQr(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnCompleteListener { imageProxy.close() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.models.ProjectSessionRef
|
||||
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.Stroke
|
||||
import wang.yaojia.webterm.designsystem.WebTermCard
|
||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
|
||||
import wang.yaojia.webterm.viewmodels.ProjectsCopy
|
||||
|
||||
/**
|
||||
* # ProjectDetailScreen (A23) — one project's detail (branch · worktrees · sessions · CLAUDE.md) plus
|
||||
* "open Claude here". Mirrors web `renderProjectDetail` / iOS `ProjectDetailScreen`.
|
||||
*
|
||||
* Every server string (name/path/branch/worktree/CLAUDE.md body) is rendered as **inert [Text]** — no
|
||||
* autolink/markdown (plan §8); the CLAUDE.md body is shown verbatim in a monospaced block. The three
|
||||
* failure buckets ([ProjectDetailViewModel.Failure]) map to copy + a retry action.
|
||||
*
|
||||
* @param onBack pop back to the projects grid.
|
||||
* @param onOpenClaude open a new session in the project cwd (`attach(null, cwd)`); the nav layer routes
|
||||
* it through [wang.yaojia.webterm.viewmodels.ProjectsViewModel.requestOpenClaude] (path re-validated).
|
||||
*/
|
||||
@Composable
|
||||
public fun ProjectDetailScreen(
|
||||
viewModel: ProjectDetailViewModel,
|
||||
onBack: () -> Unit,
|
||||
onOpenClaude: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(viewModel) { viewModel.load() }
|
||||
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
DetailHeaderBar(onBack = onBack)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (val current = phase) {
|
||||
is ProjectDetailViewModel.Phase.Loading -> Centered { CircularProgressIndicator() }
|
||||
is ProjectDetailViewModel.Phase.Failed ->
|
||||
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
|
||||
is ProjectDetailViewModel.Phase.Loaded ->
|
||||
DetailBody(detail = current.detail, onOpenClaude = onOpenClaude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailHeaderBar(onBack: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
TextButton(onClick = onBack) { Text("← 全部项目") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(Spacing.md12),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
Text(
|
||||
text = detail.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
detail.branch?.let { Text(text = it, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
if (detail.dirty == true) Text(text = "●", color = WebTermColors.statusWaiting)
|
||||
}
|
||||
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
||||
if (!detail.isGit) {
|
||||
EmptyLine("不是 git 仓库。")
|
||||
} else if (detail.worktrees.isEmpty()) {
|
||||
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
||||
} else {
|
||||
for (worktree in detail.worktrees) WorktreeRow(worktree)
|
||||
}
|
||||
|
||||
val running = detail.sessions.filter { !it.exited }
|
||||
SectionTitle("运行中的会话(${running.size})")
|
||||
if (running.isEmpty()) {
|
||||
EmptyLine("没有运行中的会话 —— 在下方开一个。")
|
||||
} else {
|
||||
for (session in running) SessionRow(session)
|
||||
}
|
||||
|
||||
SectionTitle("CLAUDE.md")
|
||||
val claudeMd = detail.claudeMd
|
||||
if (detail.hasClaudeMd && claudeMd != null) {
|
||||
ClaudeMdBlock(claudeMd)
|
||||
} else {
|
||||
EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
|
||||
}
|
||||
|
||||
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorktreeRow(worktree: WorktreeInfo) {
|
||||
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
|
||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = label, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
if (worktree.isMain) Tag("main")
|
||||
if (worktree.isCurrent) Tag("current")
|
||||
if (worktree.locked == true) Tag("locked")
|
||||
}
|
||||
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionRow(session: ProjectSessionRef) {
|
||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = session.title ?: "session",
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(text = "👁 ${session.clientCount}", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClaudeMdBlock(text: String) {
|
||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||
// Inert monospaced block — CLAUDE.md is server content; never linkify/markdown (§8).
|
||||
Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tag(text: String) {
|
||||
Text(text = text, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(top = Spacing.sm8),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLine(text: String) {
|
||||
Text(text = text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Failure(failure: ProjectDetailViewModel.Failure, onRetry: () -> Unit) {
|
||||
val message = when (failure) {
|
||||
ProjectDetailViewModel.Failure.PATH_INVALID -> "项目路径为空或不合法。"
|
||||
ProjectDetailViewModel.Failure.NOT_FOUND -> "项目不存在(路径可能已移动或删除)。"
|
||||
ProjectDetailViewModel.Failure.UNAVAILABLE -> "读取项目详情失败,请稍后再试。"
|
||||
}
|
||||
Centered {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
Text(text = message, color = MaterialTheme.colorScheme.onBackground)
|
||||
TextButton(onClick = onRetry) { Text("重试") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Centered(content: @Composable () -> Unit) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() }
|
||||
}
|
||||
|
||||
@Preview(name = "ProjectDetailScreen — loaded")
|
||||
@Composable
|
||||
private fun ProjectDetailScreenPreview() {
|
||||
val detail = ProjectDetail(
|
||||
name = "Acme.Web.frontend",
|
||||
path = "/home/dev/acme-web",
|
||||
isGit = true,
|
||||
branch = "main",
|
||||
dirty = true,
|
||||
worktrees = emptyList(),
|
||||
sessions = emptyList(),
|
||||
hasClaudeMd = true,
|
||||
claudeMd = "# CLAUDE.md\n\nProject instructions…",
|
||||
)
|
||||
WebTermTheme {
|
||||
DetailBody(detail = detail, onOpenClaude = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermCard
|
||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
import wang.yaojia.webterm.viewmodels.ProjectGroup
|
||||
import wang.yaojia.webterm.viewmodels.ProjectGroupKind
|
||||
import wang.yaojia.webterm.viewmodels.ProjectGrouping
|
||||
import wang.yaojia.webterm.viewmodels.ProjectsCopy
|
||||
import wang.yaojia.webterm.viewmodels.ProjectsUiState
|
||||
import wang.yaojia.webterm.viewmodels.ProjectsViewModel
|
||||
|
||||
/**
|
||||
* # ProjectsScreen (A23) — the grouped Projects grid (mirrors web v0.6 / iOS `ProjectsScreen`).
|
||||
*
|
||||
* Renders the presenter's [ProjectsUiState.groups] as collapsible namespace sections of project cards.
|
||||
* Every server-derived string (name/path/branch) is rendered as **inert [Text]** — plain `Text`, never
|
||||
* `ClickableText`/`LinkAnnotation`/autolink/markdown — so a hostile project name/branch can't inject a
|
||||
* tappable link (plan §8). The regular-width grid column count comes from the pure [ProjectsGridLayout]
|
||||
* seam; A26 owns the full adaptive (ListDetail/NavigationSuite) layout. All Compose/grid/gesture
|
||||
* behaviour is device-QA (plan §7).
|
||||
*
|
||||
* @param onOpenDetail open the [ProjectDetailScreen] for a tapped project path.
|
||||
*/
|
||||
@Composable
|
||||
public fun ProjectsScreen(
|
||||
viewModel: ProjectsViewModel,
|
||||
onOpenDetail: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
// Load once on entry (prefs + projects); the screen supplies the lifecycle scope for toggles.
|
||||
LaunchedEffect(viewModel) { viewModel.load() }
|
||||
ProjectsScreen(
|
||||
state = state,
|
||||
onSearchChange = viewModel::onSearchChange,
|
||||
onToggleFavourite = { path -> scope.launch { viewModel.toggleFavourite(path) } },
|
||||
onToggleCollapsed = { key -> scope.launch { viewModel.toggleCollapsed(key) } },
|
||||
onOpenDetail = onOpenDetail,
|
||||
onOpenClaude = viewModel::requestOpenClaude,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun ProjectsScreen(
|
||||
state: ProjectsUiState,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onToggleFavourite: (String) -> Unit,
|
||||
onToggleCollapsed: (String) -> Unit,
|
||||
onOpenDetail: (String) -> Unit,
|
||||
onOpenClaude: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
SearchField(value = state.searchText, onValueChange = onSearchChange)
|
||||
state.fetchError?.let { InlineNotice(it) }
|
||||
state.prefsSyncError?.let { InlineNotice(it) }
|
||||
state.openError?.let { InlineNotice(it) }
|
||||
|
||||
val emptyMessage = state.emptyStateMessage
|
||||
if (emptyMessage != null) {
|
||||
EmptyState(emptyMessage)
|
||||
} else {
|
||||
ProjectsGrid(
|
||||
state = state,
|
||||
onToggleFavourite = onToggleFavourite,
|
||||
onToggleCollapsed = onToggleCollapsed,
|
||||
onOpenDetail = onOpenDetail,
|
||||
onOpenClaude = onOpenClaude,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectsGrid(
|
||||
state: ProjectsUiState,
|
||||
onToggleFavourite: (String) -> Unit,
|
||||
onToggleCollapsed: (String) -> Unit,
|
||||
onOpenDetail: (String) -> Unit,
|
||||
onOpenClaude: (String) -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val columns = ProjectsGridLayout.columnCount(maxWidth)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(Spacing.md12),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
for (group in state.groups) {
|
||||
if (group.kind != ProjectGroupKind.FLAT) {
|
||||
item(key = "hdr:${group.key}") {
|
||||
GroupHeader(
|
||||
group = group,
|
||||
isCollapsed = state.isCollapsed(group),
|
||||
onToggle = { onToggleCollapsed(group.key) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!state.isCollapsed(group)) {
|
||||
val rows = group.projects.chunked(columns)
|
||||
rows.forEachIndexed { index, rowProjects ->
|
||||
item(key = "row:${group.key}:$index") {
|
||||
CardRow(
|
||||
rowProjects = rowProjects,
|
||||
columns = columns,
|
||||
groupKey = group.key,
|
||||
state = state,
|
||||
onToggleFavourite = onToggleFavourite,
|
||||
onOpenDetail = onOpenDetail,
|
||||
onOpenClaude = onOpenClaude,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardRow(
|
||||
rowProjects: List<ProjectInfo>,
|
||||
columns: Int,
|
||||
groupKey: String,
|
||||
state: ProjectsUiState,
|
||||
onToggleFavourite: (String) -> Unit,
|
||||
onOpenDetail: (String) -> Unit,
|
||||
onOpenClaude: (String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
for (project in rowProjects) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ProjectCard(
|
||||
project = project,
|
||||
displayName = ProjectGrouping.displayLabel(project.name, groupKey),
|
||||
isFavourite = state.isFavourite(project.path),
|
||||
onToggleFavourite = { onToggleFavourite(project.path) },
|
||||
onOpenDetail = { onOpenDetail(project.path) },
|
||||
onOpenClaude = { onOpenClaude(project.path) },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Pad a short final row so cards keep a uniform column width.
|
||||
repeat(columns - rowProjects.size) { Spacer(modifier = Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectCard(
|
||||
project: ProjectInfo,
|
||||
displayName: String,
|
||||
isFavourite: Boolean,
|
||||
onToggleFavourite: () -> Unit,
|
||||
onOpenDetail: () -> Unit,
|
||||
onOpenClaude: () -> Unit,
|
||||
) {
|
||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
Text(
|
||||
text = "★",
|
||||
color = if (isFavourite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable(onClick = onToggleFavourite),
|
||||
)
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).clickable(onClick = onOpenDetail),
|
||||
)
|
||||
if (project.dirty == true) {
|
||||
// Dirty badge: uncommitted changes. Inert glyph (§8).
|
||||
Text(text = "●", color = WebTermColors.statusWaiting)
|
||||
}
|
||||
}
|
||||
project.branch?.let { branch ->
|
||||
Text(
|
||||
text = branch,
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
val running = project.sessions.count { !it.exited }
|
||||
if (running > 0) {
|
||||
Text(
|
||||
text = ProjectsCopy.activeCountBadge(running),
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onOpenClaude) { Text(ProjectsCopy.OPEN_CLAUDE_LABEL) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupHeader(
|
||||
group: ProjectGroup,
|
||||
isCollapsed: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
val caret = when {
|
||||
!group.isCollapsible -> "●"
|
||||
isCollapsed -> "▶"
|
||||
else -> "▼"
|
||||
}
|
||||
val base = Modifier.fillMaxWidth().padding(vertical = Spacing.xs4)
|
||||
val rowModifier = if (group.isCollapsible) base.clickable(onClick = onToggle) else base
|
||||
Row(
|
||||
modifier = rowModifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Text(text = caret, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
text = group.label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(text = group.projects.size.toString(), style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (group.kind != ProjectGroupKind.ACTIVE && group.activeCount > 0) {
|
||||
Text(text = ProjectsCopy.activeCountBadge(group.activeCount), style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchField(value: String, onValueChange: (String) -> Unit) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
placeholder = { Text(ProjectsCopy.SEARCH_PROMPT) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineNotice(message: String) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(message: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(Spacing.xxl24),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.md12, Alignment.CenterVertically),
|
||||
) {
|
||||
Text(text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure regular-width → column-count seam (mirrors iOS `ProjectsGridLayout`). A23 supplies the
|
||||
* regular grid; **A26 owns the full adaptive layout** (ListDetail/NavigationSuite + iPad detents), so
|
||||
* this stays a single pure decision point, JVM-testable, with no view-tree conditionals.
|
||||
*/
|
||||
public object ProjectsGridLayout {
|
||||
/** Rise to two columns at this available width (a phone stays single-column). */
|
||||
public val twoColumnMinWidth: Dp = 560.dp
|
||||
|
||||
/** Rise to three columns on a wide tablet / desktop-class panel. */
|
||||
public val threeColumnMinWidth: Dp = 840.dp
|
||||
|
||||
/** Always `>= 1` (never 0 → never an empty grid). */
|
||||
public fun columnCount(availableWidth: Dp): Int = when {
|
||||
availableWidth >= threeColumnMinWidth -> 3
|
||||
availableWidth >= twoColumnMinWidth -> 2
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "ProjectsScreen — empty")
|
||||
@Composable
|
||||
private fun ProjectsScreenPreview() {
|
||||
WebTermTheme {
|
||||
ProjectsScreen(
|
||||
state = ProjectsUiState(hasLoadedOnce = true),
|
||||
onSearchChange = {},
|
||||
onToggleFavourite = {},
|
||||
onToggleCollapsed = {},
|
||||
onOpenDetail = {},
|
||||
onOpenClaude = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.components.SessionListRow
|
||||
import wang.yaojia.webterm.components.SessionThumbnails
|
||||
import wang.yaojia.webterm.designsystem.Radius
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.viewmodels.HostOption
|
||||
import wang.yaojia.webterm.viewmodels.SessionListUiState
|
||||
import wang.yaojia.webterm.viewmodels.SessionListViewModel
|
||||
import wang.yaojia.webterm.viewmodels.SessionRow
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* # SessionListScreen (A20) — the home session chooser + dashboard + host menu.
|
||||
*
|
||||
* The Compose shell over [SessionListViewModel]. It is the app's landing surface (plan §1: home is a
|
||||
* session chooser, not an auto-tab) AND its only settings surface — the top-bar overflow is the host
|
||||
* menu (multi-host switch with a checkmark, plus **配对新主机** → A19 pairing and **设备证书** → A27
|
||||
* cert screen).
|
||||
*
|
||||
* ### What it wires (all logic lives in the JVM-tested ViewModel)
|
||||
* - **STARTED-scoped poll** (plan §6.6): [SessionListViewModel.poll] runs inside
|
||||
* `repeatOnLifecycle(STARTED)`, so `GET /live-sessions` polling stops the instant the app backgrounds
|
||||
* and resumes on return — never in the background.
|
||||
* - **Rows** (§1): status badge · telemetry chips · preview thumbnail · cols×rows · sanitized title ·
|
||||
* unread dot — rendered by [SessionListRow]; opening a row clears its dot ([SessionListViewModel.markSeen]).
|
||||
* - **Swipe-to-kill**: a trailing `SwipeToDismissBox` calls [SessionListViewModel.killSession]
|
||||
* (optimistic remove; 204 and 404 = success; a real failure restores the row).
|
||||
* - **Pull-to-refresh**: a `PullToRefreshBox` drives [SessionListViewModel.refresh].
|
||||
*
|
||||
* Every server-influenced string on this screen (titles, cwd, telemetry) is rendered inert by
|
||||
* [SessionListRow] (plan §8). All Compose / gesture / thumbnail behaviour is device-QA (plan §7).
|
||||
*
|
||||
* @param onOpenSession open the terminal (A21) for a tapped session id (its dot is cleared first).
|
||||
* @param onNewSession start a new session on the active host (`attach(null)`; A21 owns the terminal).
|
||||
* @param onPairHost host-menu **配对新主机** → the pairing flow (A19).
|
||||
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
|
||||
* @param thumbnails off-screen preview seam; production wires it to the active host's
|
||||
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles.
|
||||
*/
|
||||
@Composable
|
||||
public fun SessionListScreen(
|
||||
viewModel: SessionListViewModel,
|
||||
onOpenSession: (UUID) -> Unit,
|
||||
onNewSession: () -> Unit,
|
||||
onPairHost: () -> Unit,
|
||||
onImportCert: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
thumbnails: SessionThumbnails? = null,
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
// STARTED-scoped poll: fetch→wait→repeat while foregrounded, cancelled on background (plan §6.6).
|
||||
LaunchedEffect(viewModel, lifecycleOwner) {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.poll()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
SessionListTopBar(
|
||||
state = state,
|
||||
onSelectHost = { id -> scope.launch { viewModel.selectHost(id) } },
|
||||
onNewSession = onNewSession,
|
||||
onPairHost = onPairHost,
|
||||
onImportCert = onImportCert,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
SessionListBody(
|
||||
state = state,
|
||||
contentPadding = padding,
|
||||
onRefresh = { scope.launch { viewModel.refresh() } },
|
||||
onOpen = { id ->
|
||||
scope.launch { viewModel.markSeen(id) }
|
||||
onOpenSession(id)
|
||||
},
|
||||
onKill = { id -> scope.launch { viewModel.killSession(id) } },
|
||||
onNewSession = onNewSession,
|
||||
onPairHost = onPairHost,
|
||||
thumbnails = thumbnails,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top bar + host menu ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SessionListTopBar(
|
||||
state: SessionListUiState,
|
||||
onSelectHost: (String) -> Unit,
|
||||
onNewSession: () -> Unit,
|
||||
onPairHost: () -> Unit,
|
||||
onImportCert: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话"
|
||||
|
||||
TopAppBar(
|
||||
title = { Text(text = activeName, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
actions = {
|
||||
TextButton(onClick = onNewSession) { Text("+ 新会话") }
|
||||
Box {
|
||||
// "⋮" text glyph avoids a material-icons dependency (not in the :app Compose bundle).
|
||||
TextButton(onClick = { menuOpen = true }) { Text("⋮") }
|
||||
HostMenu(
|
||||
expanded = menuOpen,
|
||||
hosts = state.hosts,
|
||||
onDismiss = { menuOpen = false },
|
||||
onSelectHost = { menuOpen = false; onSelectHost(it) },
|
||||
onPairHost = { menuOpen = false; onPairHost() },
|
||||
onImportCert = { menuOpen = false; onImportCert() },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the two actions. */
|
||||
@Composable
|
||||
private fun HostMenu(
|
||||
expanded: Boolean,
|
||||
hosts: List<HostOption>,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectHost: (String) -> Unit,
|
||||
onPairHost: () -> Unit,
|
||||
onImportCert: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
for (host in hosts) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = host.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
onClick = { onSelectHost(host.id) },
|
||||
leadingIcon = if (host.isActive) ({ Text("✓") }) else null,
|
||||
trailingIcon = if (host.hasDeviceCert) ({ Text("🔒") }) else null,
|
||||
)
|
||||
}
|
||||
if (hosts.isNotEmpty()) HorizontalDivider()
|
||||
DropdownMenuItem(text = { Text("配对新主机") }, onClick = onPairHost)
|
||||
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Body: pull-to-refresh over the list / empty states ─────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SessionListBody(
|
||||
state: SessionListUiState,
|
||||
contentPadding: PaddingValues,
|
||||
onRefresh: () -> Unit,
|
||||
onOpen: (UUID) -> Unit,
|
||||
onKill: (UUID) -> Unit,
|
||||
onNewSession: () -> Unit,
|
||||
onPairHost: () -> Unit,
|
||||
thumbnails: SessionThumbnails?,
|
||||
) {
|
||||
androidx.compose.material3.pulltorefresh.PullToRefreshBox(
|
||||
isRefreshing = state.isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier.fillMaxSize().padding(contentPadding),
|
||||
) {
|
||||
when {
|
||||
state.activeHostId == null ->
|
||||
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
|
||||
state.rows.isEmpty() ->
|
||||
EmptyState(
|
||||
message = if (state.hasLoadError) "无法加载会话列表。下拉重试。" else "该主机没有运行中的会话。",
|
||||
actionLabel = "开新会话",
|
||||
onAction = onNewSession,
|
||||
)
|
||||
else -> SessionList(state = state, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionList(
|
||||
state: SessionListUiState,
|
||||
onOpen: (UUID) -> Unit,
|
||||
onKill: (UUID) -> Unit,
|
||||
thumbnails: SessionThumbnails?,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(Spacing.lg16),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
if (state.hasLoadError) {
|
||||
item(key = "load-error") { LoadErrorBanner() }
|
||||
}
|
||||
items(items = state.rows, key = { it.id.toString() }) { row ->
|
||||
SwipeToKillRow(row = row, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row wrapped in a trailing (end→start) [SwipeToDismissBox]. Swiping left confirms the kill, which the
|
||||
* ViewModel handles optimistically (the row leaves the list on the next [state] emission). A start→end
|
||||
* swipe is disabled so a left-handed drag can't accidentally kill.
|
||||
*/
|
||||
@Composable
|
||||
private fun SwipeToKillRow(
|
||||
row: SessionRow,
|
||||
onOpen: (UUID) -> Unit,
|
||||
onKill: (UUID) -> Unit,
|
||||
thumbnails: SessionThumbnails?,
|
||||
) {
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||
onKill(row.id)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = false,
|
||||
backgroundContent = { KillBackground() },
|
||||
) {
|
||||
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
|
||||
}
|
||||
}
|
||||
|
||||
/** The red "删除" reveal behind a swiped row (end-aligned — the swipe is right→left). */
|
||||
@Composable
|
||||
private fun KillBackground() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.error, RoundedCornerShape(Radius.md12))
|
||||
.padding(horizontal = Spacing.lg16),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Text(text = "删除", color = MaterialTheme.colorScheme.onError, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
/** A transient-error banner shown above still-valid rows (last-good rows are kept; §"transient poll failure"). */
|
||||
@Composable
|
||||
private fun LoadErrorBanner() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8))
|
||||
.padding(Spacing.md12),
|
||||
) {
|
||||
Text(
|
||||
text = "刷新失败,显示的是上次的列表。下拉重试。",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Centered empty/error placeholder with one action; scrollable so pull-to-refresh still fires. */
|
||||
@Composable
|
||||
private fun EmptyState(message: String, actionLabel: String, onAction: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(Spacing.xxl24),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.md12, Alignment.CenterVertically),
|
||||
) {
|
||||
Text(text = message, style = MaterialTheme.typography.bodyMedium)
|
||||
Button(onClick = onAction) { Text(actionLabel) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import wang.yaojia.webterm.components.AwayDigestView
|
||||
import wang.yaojia.webterm.components.GateBanner
|
||||
import wang.yaojia.webterm.components.HardwareKeyRouter
|
||||
import wang.yaojia.webterm.components.KeyBar
|
||||
import wang.yaojia.webterm.components.PlanGateSheet
|
||||
import wang.yaojia.webterm.components.ReconnectBanner
|
||||
import wang.yaojia.webterm.components.TelemetryChips
|
||||
import wang.yaojia.webterm.designsystem.LocalReduceMotion
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||
import wang.yaojia.webterm.terminalview.RemoteTerminalView
|
||||
import wang.yaojia.webterm.wire.GateKind
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wiring.RetainedSessionHolder
|
||||
import wang.yaojia.webterm.wiring.TerminalSessionControllerImpl
|
||||
|
||||
/**
|
||||
* The arguments to open a NEW session — the pure output of the "new session in current cwd" decision.
|
||||
* `sessionId` is ALWAYS null (a fresh spawn); [cwd] is the current session's directory (`attach(null,
|
||||
* cwd)`, plan §1). A distinct type so the nav layer that consumes it (a NEW nav-scoped holder) reads a
|
||||
* stable contract.
|
||||
*/
|
||||
public data class NewSessionRequest(val sessionId: String?, val cwd: String?)
|
||||
|
||||
/**
|
||||
* The new-session-in-cwd routing decision (plan §1, §5 A21): the phone toolbar action and the exit-/
|
||||
* replay-too-large banner action both produce the SAME request — spawn a new session ([sessionId] null)
|
||||
* in the current session's [currentCwd]. Pure + JVM-testable.
|
||||
*/
|
||||
public object NewSessionInCwd {
|
||||
public fun requestFor(currentCwd: String?): NewSessionRequest =
|
||||
NewSessionRequest(sessionId = null, cwd = currentCwd)
|
||||
}
|
||||
|
||||
/**
|
||||
* # TerminalScreen (A21/A22) — the live terminal, banner, cockpit gate surfaces, key-bar and toolbar.
|
||||
*
|
||||
* Hosts the forked Termux emulator ([RemoteTerminalView]) via an [AndroidView] and wires it to the
|
||||
* holder-owned [TerminalSessionControllerImpl]. Structure top→bottom: a toolbar (sanitized title +
|
||||
* "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) + telemetry chips
|
||||
* ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with the privacy
|
||||
* cover), the [KeyBar] pinned above the IME, and — for plan gates — the [PlanGateSheet]
|
||||
* `ModalBottomSheet` overlay. Every server-derived string is rendered INERT by its component (§8).
|
||||
*
|
||||
* The Compose / AndroidView / lifecycle / privacy-shade / haptic behaviour here is DEVICE-QA (plan §7);
|
||||
* the banner reduction, gate stale-guard and new-session-in-cwd routing are the JVM-tested cores.
|
||||
*
|
||||
* ### Wiring order (FIX 1/2/3 — register-before-start, no split, config-survival)
|
||||
* - The controller + its config-surviving emulator + the retained presenters ([RetainedSessionHolder.gateViewModel],
|
||||
* [RetainedSessionHolder.bannerState], [RetainedSessionHolder.title]) are OWNED BY THE HOLDER and
|
||||
* obtained via `holder.bind()` — NEVER `remember`ed here (a remembered emulator dies on rotation).
|
||||
* `bind()` registers every consumer's `controlEvents()`/`output` mailbox EAGERLY and returns the
|
||||
* controller UN-STARTED; this screen then calls `controller.start()` exactly once. That
|
||||
* register-before-start ordering is what guarantees no `attached`/replay drop and no event split.
|
||||
* - The banner and the gate presenter each hold their OWN control mailbox (FIX 1) — collected here via
|
||||
* `collectAsStateWithLifecycle` (STARTED-scoped, §6.6).
|
||||
*
|
||||
* ### Lifecycle & config-change survival (plan §6.6)
|
||||
* - `warmUp()` runs OFF-`Main` (its own `Dispatchers.IO` hop) BEFORE the first `bind()`; if it THROWS
|
||||
* (mTLS/OkHttp build failure), the screen surfaces a retry instead of hanging not-ready (FIX 5).
|
||||
* - The engine + emulator + presenters survive config changes in the retained [holder]; the
|
||||
* `AndroidView` is keyed by the Compose-observable `holder.generation` so a REAL background→foreground
|
||||
* cycle rebuilds a fresh emulator (ring replay), while a rotation re-binds the survivor.
|
||||
* `holder.onStop(isChangingConfigurations)` drives that split.
|
||||
* - `FLAG_SECURE` + a cover Composable on `ON_STOP` keep terminal bytes out of the recents snapshot (§8).
|
||||
* - Latest-writer-wins resize: `ON_RESUME` and window-focus re-send the remembered dims via
|
||||
* `notifyForegrounded` so the active device reclaims full-screen (plan §6.4).
|
||||
*
|
||||
* @param holder the nav-scoped, config-surviving session holder (`hiltViewModel()` at the destination).
|
||||
* @param onNewSessionInCwd nav callback that opens a new session for the produced [NewSessionRequest].
|
||||
* @param onWarmUp `AppEnvironment.warmUp` — builds the shared OkHttp/mTLS stack off `Main` before bind.
|
||||
* @param onDigestExpand the A28 away-digest「展开」seam — the composition root opens the timeline sheet for
|
||||
* the current session (default no-op keeps the screen usable standalone). This only WIRES the existing
|
||||
* [AwayDigestView.onExpand][wang.yaojia.webterm.components.AwayDigestView] callback outward; no internal
|
||||
* logic changes.
|
||||
*/
|
||||
@Composable
|
||||
public fun TerminalScreen(
|
||||
holder: RetainedSessionHolder,
|
||||
endpoint: HostEndpoint,
|
||||
sessionId: String?,
|
||||
cwd: String?,
|
||||
onNewSessionInCwd: (NewSessionRequest) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onWarmUp: suspend () -> Unit = {},
|
||||
onDigestExpand: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = remember(context) { context.findActivity() }
|
||||
|
||||
// Privacy shade: FLAG_SECURE while the terminal is on screen so the recents thumbnail never leaks
|
||||
// terminal bytes (plan §8). Cleared when the screen leaves.
|
||||
DisposableEffect(activity) {
|
||||
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
onDispose { activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
|
||||
}
|
||||
|
||||
// Warm the shared OkHttp/mTLS stack OFF-Main before the first bind (A15 / AppEnvironment.warmUp).
|
||||
// FIX 5: if warmUp() throws (mTLS/OkHttp build failure off-Main), surface a retry instead of hanging
|
||||
// not-ready forever. CancellationException is rethrown so a config-change cancel is never swallowed.
|
||||
var ready by remember(endpoint, sessionId) { mutableStateOf(false) }
|
||||
var warmUpFailed by remember(endpoint, sessionId) { mutableStateOf(false) }
|
||||
var warmUpAttempt by remember(endpoint, sessionId) { mutableIntStateOf(0) }
|
||||
LaunchedEffect(endpoint, sessionId, warmUpAttempt) {
|
||||
warmUpFailed = false
|
||||
try {
|
||||
onWarmUp()
|
||||
ready = true
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Throwable) {
|
||||
warmUpFailed = true
|
||||
}
|
||||
}
|
||||
if (!ready) {
|
||||
if (warmUpFailed) WarmUpErrorPane(modifier, onRetry = { warmUpAttempt += 1 }) else LoadingPane(modifier)
|
||||
return
|
||||
}
|
||||
|
||||
// FIX 2/FIX 3: the controller (+ its config-surviving emulator) and the retained presenters are
|
||||
// OWNED BY THE HOLDER. Reading the Compose-observable generation re-keys the AndroidView on a
|
||||
// genuine-background rebuild; a config change (generation unchanged) re-binds the survivor. bind() is
|
||||
// idempotent for the same (endpoint, sessionId) and registers every mailbox before returning UN-STARTED.
|
||||
val generation = holder.generation
|
||||
val controller = holder.bind(endpoint, sessionId, cwd)
|
||||
val gateViewModel = holder.gateViewModel ?: return // populated by bind(); guarded defensively
|
||||
|
||||
// FIX 1: banner + gate each collect their OWN retained controlEvents() mailbox (folded in the holder),
|
||||
// so there is no split. Read the retained state here, STARTED-scoped (§6.6).
|
||||
val banner by holder.bannerState.collectAsStateWithLifecycle()
|
||||
val gateUi by gateViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val title by holder.title
|
||||
|
||||
// Start the engine ONCE, AFTER bind() registered every consumer's mailbox (register-before-start).
|
||||
// Idempotent: a config-change recomposition re-obtains the SAME survivor and start() is a no-op.
|
||||
LaunchedEffect(controller) { controller.start() }
|
||||
|
||||
// Arrival haptic (FIX 4): one buzz per NEW gate (rising edge), gated by the OS reduce-motion setting.
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val reduceMotion = LocalReduceMotion.current
|
||||
LaunchedEffect(gateViewModel, reduceMotion) {
|
||||
gateViewModel.gateArrivals.collect {
|
||||
if (!reduceMotion) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
}
|
||||
|
||||
// Privacy cover on ON_STOP + config-change-aware holder teardown + ON_RESUME full-screen reclaim.
|
||||
var covered by remember { mutableStateOf(false) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner, activity, controller) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
covered = true
|
||||
holder.onStop(activity?.isChangingConfigurations == true)
|
||||
}
|
||||
Lifecycle.Event.ON_START -> covered = false
|
||||
Lifecycle.Event.ON_RESUME -> controller.notifyForegrounded(null, null)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
// Window-focus reclaim (device switch) → re-send remembered dims (latest-writer-wins, §6.4).
|
||||
val windowInfo = LocalWindowInfo.current
|
||||
LaunchedEffect(windowInfo, controller) {
|
||||
snapshotFlow { windowInfo.isWindowFocused }.collect { focused ->
|
||||
if (focused) controller.notifyForegrounded(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
val requestNewSession: () -> Unit = { onNewSessionInCwd(NewSessionInCwd.requestFor(cwd)) }
|
||||
|
||||
// A slow wall-clock tick so the telemetry chips grey out as the last frame ages (§ staleness).
|
||||
val nowMs by produceState(initialValue = System.currentTimeMillis()) {
|
||||
while (true) {
|
||||
delay(TELEMETRY_TICK_MS)
|
||||
value = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
TerminalToolbar(title = title, onNewSessionInCwd = requestNewSession)
|
||||
ReconnectBanner(model = banner, onNewSession = requestNewSession)
|
||||
// Cockpit surfaces (A22): away-digest (once per reconnect) + statusLine telemetry chips. Both hide
|
||||
// themselves when their state is null/empty; server strings render inert (§8).
|
||||
gateUi.digest?.let { AwayDigestView(digest = it, onExpand = onDigestExpand) } // onExpand → A28 timeline (wired)
|
||||
gateUi.telemetry?.let { TelemetryChips(telemetry = it, nowMs = nowMs) }
|
||||
// Tool gate → an inline approve/reject card; plan gates use the ModalBottomSheet below.
|
||||
gateUi.gate?.takeIf { it.kind == GateKind.TOOL }?.let { toolGate ->
|
||||
GateBanner(gate = toolGate, onDecide = gateViewModel::decide)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
key(generation) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
val remoteView = RemoteTerminalView(ctx)
|
||||
// Hardware chords (DECCKM split): Esc/Ctrl-letter/⇧Tab app-side, arrows/Enter/Tab
|
||||
// deferred to Termux's KeyHandler (returns false) — plan §6.3.
|
||||
remoteView.onKeyCommand = { keyCode, keyEvent ->
|
||||
HardwareKeyRouter.handle(keyCode, keyEvent, controller::sendInput)
|
||||
}
|
||||
// A layout/size change feeds the latest-writer-wins reclaim path (§6.4). The pixel→
|
||||
// grid metric read (Termux's package-private mFontWidth) + emulator resize is
|
||||
// completed in the view layer on-device; here we reclaim full-screen.
|
||||
remoteView.onViewSizeChanged = { _, _ -> controller.notifyForegrounded(null, null) }
|
||||
controller.remoteSession.attachView(remoteView)
|
||||
remoteView.hostView()
|
||||
},
|
||||
// Config change / real background: unbind the view but the holder-owned emulator
|
||||
// survives (FIX 3) — never close it here (that is the holder teardown's job).
|
||||
onRelease = { controller.remoteSession.detachView() },
|
||||
)
|
||||
}
|
||||
if (covered) PrivacyCover()
|
||||
}
|
||||
// The IME-bypass touch key-bar, pinned above the keyboard (hidden on wide screens / hardware kbd).
|
||||
KeyBar(onSend = controller::sendInput)
|
||||
}
|
||||
|
||||
// Plan gate → a three-way ModalBottomSheet overlay. Dismissing (scrim/back) resolves NOTHING — the
|
||||
// gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in
|
||||
// GateViewModel.decide.
|
||||
gateUi.gate?.takeIf { it.kind == GateKind.PLAN }?.let { planGate ->
|
||||
PlanGateSheet(gate = planGate, onDecide = gateViewModel::decide, onDismiss = {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Telemetry-chip staleness tick interval (ms) — re-reads the wall clock so chips grey as a frame ages. */
|
||||
private const val TELEMETRY_TICK_MS: Long = 1_000L
|
||||
|
||||
/** Toolbar: the inert (already-sanitized) session title + the new-session-in-cwd action. */
|
||||
@Composable
|
||||
private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
||||
) {
|
||||
Text(
|
||||
text = title.ifEmpty { "终端" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The privacy cover shown while backgrounded so the recents snapshot never shows terminal content. */
|
||||
@Composable
|
||||
private fun PrivacyCover() {
|
||||
Surface(color = WebTermColors.terminalBackground, modifier = Modifier.fillMaxSize()) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||
Text("WebTerm", color = WebTermColors.terminalForeground.copy(alpha = 0.4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Placeholder while the shared OkHttp/mTLS stack warms up off-Main before the first bind. */
|
||||
@Composable
|
||||
private fun LoadingPane(modifier: Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("连接中…", color = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when `warmUp()` threw building the mTLS/OkHttp stack (FIX 5) — an actionable retry instead of a
|
||||
* permanent not-ready hang. The failure detail is intentionally NOT rendered (it may carry mTLS/host
|
||||
* internals); the copy is a fixed, inert string.
|
||||
*/
|
||||
@Composable
|
||||
private fun WarmUpErrorPane(modifier: Modifier, onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(Spacing.lg16),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("连接初始化失败", color = MaterialTheme.colorScheme.onBackground)
|
||||
TextButton(onClick = onRetry) { Text("重试") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Unwrap a [Context] to its host [Activity] (for FLAG_SECURE + isChangingConfigurations). */
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The [android.view.View] to add to the Compose tree — the Termux `TerminalView` behind
|
||||
* [RemoteTerminalView].
|
||||
*
|
||||
* BOUNDARY WORKAROUND (recorded): A16's `RemoteTerminalView.terminalView` is typed
|
||||
* `com.termux.view.TerminalView`, but `:terminal-view` scopes the Termux `terminal-view` artifact as
|
||||
* `implementation`, so that concrete type is NOT on `:app`'s compile classpath (and neither
|
||||
* `app/build.gradle.kts` nor `:terminal-view`/`RemoteTerminalView` is in A21's editable lane). We
|
||||
* therefore read the already-attached stock view through its public getter as a plain [View] — a
|
||||
* `TerminalView` IS a `View`, so hosting it in an `AndroidView` is correct; only the compile-time type
|
||||
* name is avoided. The clean fix (out of A21's lane) is to `api`-expose the Termux view from
|
||||
* `:terminal-view` or add `libs.termux.terminal.view` to `:app`, then `AndroidView { remote.terminalView }`.
|
||||
*/
|
||||
private fun RemoteTerminalView.hostView(): View =
|
||||
RemoteTerminalView::class.java.getMethod("getTerminalView").invoke(this) as View
|
||||
@@ -0,0 +1,234 @@
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.viewmodels.TimelineCopy
|
||||
import wang.yaojia.webterm.viewmodels.TimelinePhase
|
||||
import wang.yaojia.webterm.viewmodels.TimelineRow
|
||||
import wang.yaojia.webterm.viewmodels.TimelineViewModel
|
||||
|
||||
/** Min sheet body height so the loading / empty / error states don't collapse to a sliver. */
|
||||
private val SHEET_MIN_HEIGHT = 240.dp
|
||||
|
||||
/** Fixed width for the glyph column so the HH:mm / label columns line up across rows. */
|
||||
private val GLYPH_COLUMN_WIDTH = 28.dp
|
||||
|
||||
/**
|
||||
* # TimelineSheet (A28) — the full activity-timeline drill-down, as a Material3 [ModalBottomSheet].
|
||||
*
|
||||
* Opened by the away-digest「展开」affordance (`AwayDigestView.onExpand`, A22 seam): the caller builds a
|
||||
* fresh [TimelineViewModel] via [TimelineViewModel.forSession] (id + `ApiClient::events`), shows this
|
||||
* sheet, and passes [onDismiss] to tear it down. A fresh VM per presentation → exactly one fetch, kicked
|
||||
* by the [LaunchedEffect] below.
|
||||
*
|
||||
* Rows mirror the web timeline panel — "HH:mm · glyph · label", newest-first, capped (the reducer lives
|
||||
* in [TimelineViewModel]). The label is **untrusted server text**: rendered as an INERT [Text], single
|
||||
* line, no linkify/markdown (§8). The Compose surface here is DEVICE-QA (plan §7); the fetch/present/
|
||||
* mapping cores are the JVM-tested logic in [TimelineViewModel].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
public fun TimelineSheet(
|
||||
viewModel: TimelineViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
) {
|
||||
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// One fetch per presentation (fresh VM ⇒ fresh load); the「重试」button re-runs the same path.
|
||||
LaunchedEffect(viewModel) { viewModel.load() }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||
TimelineSheetContent(
|
||||
phase = phase,
|
||||
onRetry = { scope.launch { viewModel.load() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The phase switch + rows — split out so it previews without a live [ModalBottomSheet] host. */
|
||||
@Composable
|
||||
private fun TimelineSheetContent(
|
||||
phase: TimelinePhase,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = SHEET_MIN_HEIGHT)
|
||||
.padding(horizontal = Spacing.lg16),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Text(
|
||||
text = TimelineCopy.TITLE,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(vertical = Spacing.sm8),
|
||||
)
|
||||
when (phase) {
|
||||
is TimelinePhase.Loading -> CenteredBox { CircularProgressIndicator() }
|
||||
is TimelinePhase.Empty -> EmptyState()
|
||||
is TimelinePhase.Failed -> FailedState(onRetry = onRetry)
|
||||
is TimelinePhase.Loaded -> TimelineRowList(rows = phase.rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineRowList(rows: List<TimelineRow>) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
items(items = rows, key = { it.key }) { row -> TimelineRowView(row) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineRowView(row: TimelineRow) {
|
||||
// Unknown/future class → theme-adaptive secondary text (colorSpec == null; §8 fallback).
|
||||
val glyphColor = row.colorSpec?.let { Color(it) } ?: MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = Spacing.xs2),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Monospace so the HH:mm column lines up across rows (tabular direction).
|
||||
Text(
|
||||
text = row.time,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = row.glyph,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = glyphColor,
|
||||
modifier = Modifier.width(GLYPH_COLUMN_WIDTH),
|
||||
)
|
||||
// INERT: server-derived phrase rendered verbatim, hard single-line truncation (§8).
|
||||
Text(
|
||||
text = row.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = Spacing.xl20),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
Text(
|
||||
text = TimelineCopy.EMPTY_TITLE,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = TimelineCopy.EMPTY_DETAIL,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailedState(onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = Spacing.xl20),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Text(
|
||||
text = TimelineCopy.LOAD_FAILED,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = TimelineCopy.LOAD_FAILED_DETAIL,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(onClick = onRetry) { Text(text = TimelineCopy.RETRY) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredBox(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = Spacing.xl20),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "TimelineSheet · loaded")
|
||||
@Composable
|
||||
private fun TimelineSheetLoadedPreview() {
|
||||
WebTermTheme {
|
||||
TimelineSheetContent(
|
||||
phase = TimelinePhase.Loaded(
|
||||
listOf(
|
||||
TimelineRow(0, "13:05", "✓", 0xFF46D07FL, "finished"),
|
||||
TimelineRow(1, "13:04", "⏳", 0xFFF5B14CL, "waiting for approval"),
|
||||
TimelineRow(2, "13:03", "🔧", 0xFF5E9EFFL, "ran Bash"),
|
||||
),
|
||||
),
|
||||
onRetry = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "TimelineSheet · empty")
|
||||
@Composable
|
||||
private fun TimelineSheetEmptyPreview() {
|
||||
WebTermTheme { TimelineSheetContent(phase = TimelinePhase.Empty, onRetry = {}) }
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.clienttls.CertificateSummary
|
||||
import wang.yaojia.webterm.clienttls.NoClientIdentityException
|
||||
import wang.yaojia.webterm.clienttls.Pkcs12DecodeException
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* # ClientCertViewModel (A27) — the device-certificate (mTLS) management presenter.
|
||||
*
|
||||
* Drives the "设备证书" screen reached from the session-list host menu (A20): import a `.p12`, rotate
|
||||
* it, remove it, and show the installed identity's summary (subject-CN / issuer-CN / expiry, with an
|
||||
* **EXPIRED** warning). Ports the iOS `ClientCertViewModel` over the shared [IdentityRepository]
|
||||
* (`:client-tls-android`) — the ONE key home; this presenter never touches AndroidKeyStore/Tink
|
||||
* directly, it only funnels bytes+passphrase into the repository and renders the returned
|
||||
* [CertificateSummary].
|
||||
*
|
||||
* ### The security invariant this screen must not break (plan §8 "Cert storage")
|
||||
* A `.p12` import **validates before persisting**, so a bad passphrase can NEVER clobber a
|
||||
* previously-installed identity — the repository throws before mutating either store, the prior cert
|
||||
* stays live, and this VM merely surfaces the error and leaves [ClientCertUiState.summary] untouched
|
||||
* (the on-screen cert is unchanged). Rotate and remove additionally evict pooled connections inside
|
||||
* the repository so nothing reuses the old identity; that is the repository's concern, not this VM's.
|
||||
*
|
||||
* ### Secrets discipline (plan §8)
|
||||
* The passphrase and the `.p12` bytes are passed straight to the repository and are **never logged,
|
||||
* never persisted, and never placed in fallback/error copy** — errors are a coarse [CertError] enum
|
||||
* the screen maps to app-authored, inert copy. No server/cert string is ever rendered as anything but
|
||||
* inert [androidx.compose.material3.Text].
|
||||
*
|
||||
* ### A plain presenter (JVM-testable, mirrors [PairingViewModel] / [DiffViewModel])
|
||||
* Not an `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no
|
||||
* `Dispatchers.Main` / Robolectric. The screen calls [bind] with a lifecycle scope (which kicks the
|
||||
* initial summary load) and then the import/rotate/remove actions. Because the repository's reads and
|
||||
* mutations do blocking AndroidKeyStore/Tink I/O, every touch hops to [io] (`Dispatchers.IO` in
|
||||
* production) so nothing blocks the UI thread; the SAF file read itself happens in the Compose shell.
|
||||
*
|
||||
* @param repository the shared mTLS device identity ([IdentityRepository]); a fake in tests.
|
||||
* @param io the dispatcher the blocking keystore reads/mutations hop to (off `Main`); overridable in tests.
|
||||
* @param zone the zone the not-after date is formatted in (default device zone; fixed in tests).
|
||||
* @param now the clock used for the EXPIRED check (default wall clock; fixed in tests).
|
||||
*/
|
||||
public class ClientCertViewModel(
|
||||
private val repository: IdentityRepository,
|
||||
private val io: CoroutineContext = Dispatchers.IO,
|
||||
private val zone: ZoneId = ZoneId.systemDefault(),
|
||||
private val now: () -> Instant = Instant::now,
|
||||
) {
|
||||
private val _uiState = MutableStateFlow(ClientCertUiState())
|
||||
|
||||
/** The single snapshot the cert screen renders from. */
|
||||
public val uiState: StateFlow<ClientCertUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
// A single in-flight op handle. The initial read may be superseded by a mutation; a mutation is
|
||||
// never cancelled by another (the `BUSY` guard blocks a concurrent one), so a commit can't be
|
||||
// interrupted mid-flight by this VM.
|
||||
private var job: Job? = null
|
||||
|
||||
/** Bind the scope actions launch into (the screen passes a lifecycle scope) and load the summary. */
|
||||
public fun bind(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
val scope = scope ?: return
|
||||
job?.cancel()
|
||||
_uiState.value = _uiState.value.copy(phase = CertPhase.LOADING)
|
||||
job = scope.launch {
|
||||
// currentSummary() is designed to degrade to null on a storage fault (never throw), but
|
||||
// guard anyway so a read fault becomes "no cert shown" rather than a crash.
|
||||
val summary = runCatching { withContext(io) { repository.currentSummary() } }.getOrNull()
|
||||
_uiState.value = _uiState.value.copy(phase = CertPhase.READY, summary = summary?.toView())
|
||||
}
|
||||
}
|
||||
|
||||
/** Import a `.p12` as the device identity (no cert currently installed). Validates before persisting. */
|
||||
public fun import(p12Bytes: ByteArray, passphrase: String) {
|
||||
install(p12Bytes, passphrase, rotate = false)
|
||||
}
|
||||
|
||||
/** Replace the installed identity with a new `.p12` (rotation). The prior cert stays live on failure. */
|
||||
public fun rotate(p12Bytes: ByteArray, passphrase: String) {
|
||||
install(p12Bytes, passphrase, rotate = true)
|
||||
}
|
||||
|
||||
private fun install(p12Bytes: ByteArray, passphrase: String, rotate: Boolean) {
|
||||
val scope = scope ?: return
|
||||
if (_uiState.value.phase == CertPhase.BUSY) return // ignore a double-tap while an op is in flight
|
||||
job?.cancel() // supersede only the (safe-to-drop) initial read; never an in-flight mutation
|
||||
_uiState.value = _uiState.value.copy(phase = CertPhase.BUSY, error = null)
|
||||
job = scope.launch {
|
||||
val outcome = try {
|
||||
val summary = withContext(io) {
|
||||
if (rotate) repository.rotate(p12Bytes, passphrase)
|
||||
else repository.importIdentity(p12Bytes, passphrase)
|
||||
}
|
||||
Result.success(summary)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
_uiState.value = outcome.fold(
|
||||
// Success: the returned summary is the newly-committed identity.
|
||||
onSuccess = { s -> _uiState.value.copy(phase = CertPhase.READY, summary = s.toView(), error = null) },
|
||||
// Failure: validate-before-persist means the PRIOR identity is untouched — leave
|
||||
// `summary` exactly as it was and only surface the classified error.
|
||||
onFailure = { e -> _uiState.value.copy(phase = CertPhase.READY, error = classify(e)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask to remove the installed identity — raises the confirm dialog (destructive, so gated). */
|
||||
public fun requestRemove() {
|
||||
if (_uiState.value.summary == null) return // nothing to remove
|
||||
_uiState.value = _uiState.value.copy(confirmingRemove = true)
|
||||
}
|
||||
|
||||
/** Dismiss the remove-confirm dialog without removing anything. */
|
||||
public fun cancelRemove() {
|
||||
_uiState.value = _uiState.value.copy(confirmingRemove = false)
|
||||
}
|
||||
|
||||
/** Confirm removal: clear the device identity (idempotent in the repository). */
|
||||
public fun confirmRemove() {
|
||||
val scope = scope ?: return
|
||||
_uiState.value = _uiState.value.copy(confirmingRemove = false, phase = CertPhase.BUSY, error = null)
|
||||
job?.cancel()
|
||||
job = scope.launch {
|
||||
val outcome = try {
|
||||
withContext(io) { repository.remove() }
|
||||
Result.success(Unit)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
_uiState.value = outcome.fold(
|
||||
onSuccess = { _uiState.value.copy(phase = CertPhase.READY, summary = null) },
|
||||
// A remove fault keeps whatever summary was showing (the identity may still be live).
|
||||
onFailure = { _uiState.value.copy(phase = CertPhase.READY, error = CertError.REMOVE_FAILED) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the current error banner. */
|
||||
public fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
private fun CertificateSummary.toView(): CertSummaryView = toSummaryView(now(), zone)
|
||||
}
|
||||
|
||||
/** Map an import/rotation throwable to the coarse, non-secret [CertError] the screen renders inertly. */
|
||||
internal fun classify(error: Throwable): CertError = when (error) {
|
||||
is UnrecoverableKeyException -> CertError.BAD_PASSPHRASE
|
||||
is Pkcs12DecodeException -> CertError.INVALID_FILE
|
||||
is NoClientIdentityException -> CertError.NO_IDENTITY
|
||||
else -> CertError.IMPORT_FAILED
|
||||
}
|
||||
|
||||
/** The load/action phase the screen renders (spinner vs. content vs. an in-flight import/rotate/remove). */
|
||||
public enum class CertPhase {
|
||||
/** The initial summary read is in flight. */
|
||||
LOADING,
|
||||
|
||||
/** Idle — [ClientCertUiState.summary] is either an installed cert or `null` (none installed). */
|
||||
READY,
|
||||
|
||||
/** An import / rotate / remove is committing. */
|
||||
BUSY,
|
||||
}
|
||||
|
||||
/** The coarse, non-secret failure taxonomy — the screen maps each to app-authored inert copy (§8). */
|
||||
public enum class CertError {
|
||||
/** Wrong passphrase (the `.p12` MAC/integrity check failed) — the prior identity is untouched. */
|
||||
BAD_PASSPHRASE,
|
||||
|
||||
/** The file is not a decodable PKCS#12 blob (truncated / not a `.p12` / unsupported). */
|
||||
INVALID_FILE,
|
||||
|
||||
/** Decoded, but the `.p12` carried no importable private-key entry (certs-only / truststore). */
|
||||
NO_IDENTITY,
|
||||
|
||||
/** Any other import/rotation failure (keystore/storage fault). */
|
||||
IMPORT_FAILED,
|
||||
|
||||
/** Removal failed — the identity may still be installed. */
|
||||
REMOVE_FAILED,
|
||||
}
|
||||
|
||||
/** The immutable snapshot the device-cert screen renders. */
|
||||
public data class ClientCertUiState(
|
||||
/** The installed device identity's display summary, or `null` when none is installed. */
|
||||
val summary: CertSummaryView? = null,
|
||||
/** The load/action phase. */
|
||||
val phase: CertPhase = CertPhase.LOADING,
|
||||
/** The last import/rotate/remove failure, or `null`. Surfaced as inert, app-authored copy. */
|
||||
val error: CertError? = null,
|
||||
/** Whether the destructive remove-confirm dialog is up. */
|
||||
val confirmingRemove: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* The rendered device-certificate summary — the presentation view of a [CertificateSummary]. Absent
|
||||
* fields degrade to [UNKNOWN] (display-only; the TLS stack, not this label, is the real gate) and the
|
||||
* not-after instant is pre-formatted to a local date. [isExpired] drives the on-screen warning.
|
||||
*/
|
||||
public data class CertSummaryView(
|
||||
val subjectCommonName: String,
|
||||
val issuerCommonName: String,
|
||||
val expiry: String,
|
||||
val isExpired: Boolean,
|
||||
) {
|
||||
public companion object {
|
||||
/** Shown for a CN/expiry the certificate did not carry (or that failed to parse). */
|
||||
public const val UNKNOWN: String = "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// Medium date style (e.g. locale-formatted "2027年1月8日" / "Jan 8, 2027") — date only; time is noise here.
|
||||
private val EXPIRY_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
|
||||
/**
|
||||
* Pure presentation of a [CertificateSummary] (independently unit-tested): CN fields fall back to
|
||||
* [CertSummaryView.UNKNOWN], the not-after instant is formatted in [zone], and [now] decides the
|
||||
* EXPIRED flag (delegated to [CertificateSummary.isExpired] — unknown expiry is treated as not
|
||||
* expired, matching iOS).
|
||||
*/
|
||||
public fun CertificateSummary.toSummaryView(now: Instant, zone: ZoneId): CertSummaryView =
|
||||
CertSummaryView(
|
||||
subjectCommonName = subjectCommonName ?: CertSummaryView.UNKNOWN,
|
||||
issuerCommonName = issuerCommonName ?: CertSummaryView.UNKNOWN,
|
||||
expiry = notAfter?.let { EXPIRY_FORMATTER.withZone(zone).format(it) } ?: CertSummaryView.UNKNOWN,
|
||||
isExpired = isExpired(now),
|
||||
)
|
||||
@@ -0,0 +1,342 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* # DiffViewModel (A24) — the read-only staged/unstaged git-diff presenter.
|
||||
*
|
||||
* Fetches `GET /projects/diff?path=&staged=1|0` (plan §4.2), tolerantly decodes the untrusted
|
||||
* [DiffResult], and **flattens** files→hunks→lines into ONE ordered [DiffRow] list a `LazyColumn`
|
||||
* renders (plan §5 A24, §1 "Diff viewer"). All rendering is inert monospaced text (plan §8) —
|
||||
* this presenter carries only the pre-classified line kinds; colouring lives in `DiffScreen`.
|
||||
*
|
||||
* ### Why the diff route/model live HERE (not in `:api-client`)
|
||||
* `:api-client`'s frozen 12-route surface (A8) does not include the diff route, and A24 owns only the
|
||||
* `:app` diff files. So the diff DTOs + tolerant decode + the RO request are self-contained in this
|
||||
* module, consuming only the frozen [HttpTransport]/[HostEndpoint] seam — the same untrusted-boundary
|
||||
* discipline `:api-client` uses (unknown keys ignored, malformed file/hunk/line dropped one by one,
|
||||
* nothing throws on bad server input).
|
||||
*
|
||||
* ### `staged` is the STRING `"1"`/`"0"` (NOT a boolean)
|
||||
* The server matches `req.query['staged'] === '1'` (`src/server.ts`), so the query value MUST be the
|
||||
* literal `"1"` (staged) or `"0"` (working tree) — a `true`/`false` would silently read as working-tree
|
||||
* on the server. [diffUrl] encodes exactly that; the JVM test asserts the exact query value.
|
||||
*
|
||||
* ### A plain presenter (JVM-testable, mirrors [PairingViewModel]/[GateViewModel])
|
||||
* Not an `androidx.lifecycle.ViewModel`, so it runs under `runTest` with no `Dispatchers.Main`. The
|
||||
* screen calls [bind] with a lifecycle scope, then [refresh]; the Working/Staged toggle calls
|
||||
* [selectStaged]. Each (re)load cancels the in-flight fetch so a fast toggle never races.
|
||||
*/
|
||||
public class DiffViewModel(
|
||||
private val fetcher: DiffFetcher,
|
||||
private val path: String,
|
||||
) {
|
||||
private val _uiState = MutableStateFlow(DiffUiState())
|
||||
|
||||
/** The single snapshot `DiffScreen` renders from. */
|
||||
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
private var job: Job? = null
|
||||
|
||||
/** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
|
||||
public fun bind(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
reload()
|
||||
}
|
||||
|
||||
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches. */
|
||||
public fun selectStaged(staged: Boolean) {
|
||||
if (_uiState.value.staged == staged) return
|
||||
_uiState.value = _uiState.value.copy(staged = staged)
|
||||
reload()
|
||||
}
|
||||
|
||||
/** Re-fetch the current view (pull-to-refresh / retry after an error). */
|
||||
public fun refresh() {
|
||||
reload()
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
val scope = scope ?: return
|
||||
job?.cancel()
|
||||
val staged = _uiState.value.staged
|
||||
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
|
||||
job = scope.launch {
|
||||
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
|
||||
val outcome = try {
|
||||
Result.success(fetcher.fetch(path, staged))
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
val current = _uiState.value
|
||||
_uiState.value = outcome.fold(
|
||||
onSuccess = { result ->
|
||||
current.copy(
|
||||
phase = if (result.files.isEmpty()) DiffPhase.EMPTY else DiffPhase.LOADED,
|
||||
rows = flattenDiff(result),
|
||||
truncated = result.truncated,
|
||||
)
|
||||
},
|
||||
onFailure = { current.copy(phase = DiffPhase.ERROR, rows = emptyList(), truncated = false) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The load phase the screen renders (loading spinner / empty / error / list). */
|
||||
public enum class DiffPhase { IDLE, LOADING, LOADED, EMPTY, ERROR }
|
||||
|
||||
/** The immutable snapshot the diff screen renders. */
|
||||
public data class DiffUiState(
|
||||
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. */
|
||||
val staged: Boolean = false,
|
||||
val phase: DiffPhase = DiffPhase.IDLE,
|
||||
/** files→hunks→lines flattened into one ordered list (empty until loaded). */
|
||||
val rows: List<DiffRow> = emptyList(),
|
||||
/** Server capped the diff (too large) — the screen shows a truncation notice. */
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
// ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────
|
||||
|
||||
/** One rendered row. A sealed hierarchy so each kind paints with its own token (plan §1/§8). */
|
||||
public sealed interface DiffRow {
|
||||
/** Stable, monotonic id — the `LazyColumn` item key (row identity survives recomposition). */
|
||||
public val id: Long
|
||||
}
|
||||
|
||||
/** A per-file header: the display path plus its `+added/-removed` numstat and status. */
|
||||
public data class DiffFileHeaderRow(
|
||||
override val id: Long,
|
||||
val path: String,
|
||||
val status: String,
|
||||
val added: Int,
|
||||
val removed: Int,
|
||||
) : DiffRow
|
||||
|
||||
/** A hunk header line (`@@ -a,b +c,d @@`) — the wire `hunk` kind. */
|
||||
public data class DiffHunkHeaderRow(override val id: Long, val header: String) : DiffRow
|
||||
|
||||
/** One diff line, pre-classified by the server (added/removed/context/meta). */
|
||||
public data class DiffLineRow(override val id: Long, val kind: DiffLineKind, val text: String) : DiffRow
|
||||
|
||||
/** A binary file has no hunks — a single "Binary file" marker row. */
|
||||
public data class DiffBinaryRow(override val id: Long) : DiffRow
|
||||
|
||||
/**
|
||||
* Flatten a [DiffResult] into the ordered render list: for each file a [DiffFileHeaderRow], then —
|
||||
* for a binary file a single [DiffBinaryRow], otherwise each hunk's [DiffHunkHeaderRow] followed by
|
||||
* its [DiffLineRow]s. Order is preserved exactly (files, then hunks within a file, then lines within
|
||||
* a hunk). Pure — JVM-tested.
|
||||
*/
|
||||
public fun flattenDiff(result: DiffResult): List<DiffRow> {
|
||||
val rows = ArrayList<DiffRow>()
|
||||
var id = 0L
|
||||
for (file in result.files) {
|
||||
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.status, file.added, file.removed))
|
||||
if (file.binary) {
|
||||
rows.add(DiffBinaryRow(id++))
|
||||
continue
|
||||
}
|
||||
for (hunk in file.hunks) {
|
||||
rows.add(DiffHunkHeaderRow(id++, hunk.header))
|
||||
for (line in hunk.lines) {
|
||||
rows.add(DiffLineRow(id++, line.kind, line.text))
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** `old → new` for a real rename, else the new path (mirror of `public/diff.ts` renderDiffFile). */
|
||||
private fun headerPath(file: DiffFile): String =
|
||||
if (file.status == "renamed" && file.oldPath != file.newPath) {
|
||||
"${file.oldPath} → ${file.newPath}"
|
||||
} else {
|
||||
file.newPath
|
||||
}
|
||||
|
||||
// ── The diff model (plain, non-@Serializable — decoded by hand from the JSON tree) ───────────────
|
||||
|
||||
/** The semantic class of one diff line (`src/types.ts` `DiffLineKind`). Unknown → [CONTEXT]. */
|
||||
public enum class DiffLineKind {
|
||||
ADDED,
|
||||
REMOVED,
|
||||
CONTEXT,
|
||||
META,
|
||||
HUNK,
|
||||
;
|
||||
|
||||
public companion object {
|
||||
/** Map the wire string; an unknown/future kind degrades to [CONTEXT] (mirror of the web default). */
|
||||
public fun fromWire(wire: String): DiffLineKind = when (wire) {
|
||||
"added" -> ADDED
|
||||
"removed" -> REMOVED
|
||||
"context" -> CONTEXT
|
||||
"meta" -> META
|
||||
"hunk" -> HUNK
|
||||
else -> CONTEXT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public data class DiffLine(val kind: DiffLineKind, val text: String)
|
||||
public data class DiffHunk(val header: String, val lines: List<DiffLine>)
|
||||
public data class DiffFile(
|
||||
val oldPath: String,
|
||||
val newPath: String,
|
||||
val status: String,
|
||||
val added: Int,
|
||||
val removed: Int,
|
||||
val binary: Boolean,
|
||||
val hunks: List<DiffHunk>,
|
||||
)
|
||||
|
||||
public data class DiffResult(val files: List<DiffFile>, val staged: Boolean, val truncated: Boolean)
|
||||
|
||||
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
|
||||
private val DiffJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an untrusted `/projects/diff` body into [DiffResult], dropping malformed files/hunks/lines
|
||||
* one by one and keeping the rest (plan §4.2 lossy decode). Never throws: a non-object root or
|
||||
* unparseable body yields an empty result.
|
||||
*/
|
||||
internal fun decodeDiffResult(bytes: ByteArray): DiffResult {
|
||||
val root = runCatching { DiffJson.parseToJsonElement(bytes.decodeToString()) }
|
||||
.getOrNull() as? JsonObject
|
||||
?: return DiffResult(emptyList(), staged = false, truncated = false)
|
||||
val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
|
||||
return DiffResult(files = files, staged = root.bool("staged", false), truncated = root.bool("truncated", false))
|
||||
}
|
||||
|
||||
/** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */
|
||||
private fun decodeFile(element: kotlinx.serialization.json.JsonElement): DiffFile? {
|
||||
val obj = element as? JsonObject ?: return null
|
||||
val newPath = obj.str("newPath") ?: return null
|
||||
val hunks = (obj["hunks"] as? JsonArray).orEmpty().mapNotNull(::decodeHunk)
|
||||
return DiffFile(
|
||||
oldPath = obj.str("oldPath") ?: newPath,
|
||||
newPath = newPath,
|
||||
status = obj.str("status") ?: "modified",
|
||||
added = obj.int("added", 0),
|
||||
removed = obj.int("removed", 0),
|
||||
binary = obj.bool("binary", false),
|
||||
hunks = hunks,
|
||||
)
|
||||
}
|
||||
|
||||
/** A hunk needs a string `header`; a malformed hunk is dropped (its siblings survive). */
|
||||
private fun decodeHunk(element: kotlinx.serialization.json.JsonElement): DiffHunk? {
|
||||
val obj = element as? JsonObject ?: return null
|
||||
val header = obj.str("header") ?: return null
|
||||
val lines = (obj["lines"] as? JsonArray).orEmpty().mapNotNull(::decodeLine)
|
||||
return DiffHunk(header = header, lines = lines)
|
||||
}
|
||||
|
||||
/** A line needs both `kind` and `text` as strings; otherwise it is dropped. */
|
||||
private fun decodeLine(element: kotlinx.serialization.json.JsonElement): DiffLine? {
|
||||
val obj = element as? JsonObject ?: return null
|
||||
val kind = obj.str("kind") ?: return null
|
||||
val text = obj.str("text") ?: return null
|
||||
return DiffLine(kind = DiffLineKind.fromWire(kind), text = text)
|
||||
}
|
||||
|
||||
private fun JsonObject.str(key: String): String? =
|
||||
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
private fun JsonObject.int(key: String, default: Int): Int =
|
||||
(this[key] as? JsonPrimitive)?.intOrNull ?: default
|
||||
|
||||
private fun JsonObject.bool(key: String, default: Boolean): Boolean =
|
||||
(this[key] as? JsonPrimitive)?.booleanOrNull ?: default
|
||||
|
||||
// ── Fetch (RO GET — no Origin header, plan §4.2/§4.3) ────────────────────────────────────────────
|
||||
|
||||
/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
|
||||
public interface DiffFetcher {
|
||||
/** @throws DiffUnavailable on a non-200 status; transport errors propagate. */
|
||||
public suspend fun fetch(path: String, staged: Boolean): DiffResult
|
||||
}
|
||||
|
||||
/** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */
|
||||
public class DiffUnavailable(public val status: Int) : Exception("diff unavailable: HTTP $status")
|
||||
|
||||
/**
|
||||
* Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly.
|
||||
* Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3).
|
||||
*/
|
||||
public class HttpDiffFetcher(
|
||||
private val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
) : DiffFetcher {
|
||||
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
|
||||
val url = diffUrl(endpoint.baseUrl, path, staged) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
|
||||
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
|
||||
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
|
||||
return decodeDiffResult(response.body)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HTTP_OK = 200
|
||||
private const val HTTP_BAD_REQUEST = 400
|
||||
|
||||
/**
|
||||
* Build `<scheme>://host[:port]/projects/diff?path=<enc>&staged=1|0` from the dialed base URL,
|
||||
* keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). `staged` serializes as the
|
||||
* literal `"1"`/`"0"` string the server matches with `=== '1'` (NOT a boolean). Returns null if the
|
||||
* base URL cannot be parsed. `internal` so the JVM test asserts the exact query value.
|
||||
*/
|
||||
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean): String? {
|
||||
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
val host = uri.host ?: return null
|
||||
if (host.isEmpty()) return null
|
||||
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||
val stagedValue = if (staged) "1" else "0"
|
||||
return "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}&staged=$stagedValue"
|
||||
}
|
||||
|
||||
/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */
|
||||
private val UNRESERVED: Set<Char> =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
|
||||
|
||||
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||
|
||||
private fun percentEncode(value: String): String {
|
||||
val sb = StringBuilder()
|
||||
for (byte in value.encodeToByteArray()) {
|
||||
val code = byte.toInt() and 0xFF
|
||||
val ch = code.toChar()
|
||||
if (ch in UNRESERVED) {
|
||||
sb.append(ch)
|
||||
} else {
|
||||
sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.session.AwayDigest
|
||||
import wang.yaojia.webterm.session.Digest
|
||||
import wang.yaojia.webterm.session.Exited
|
||||
import wang.yaojia.webterm.session.Gate
|
||||
import wang.yaojia.webterm.session.GateState
|
||||
import wang.yaojia.webterm.session.SessionEvent
|
||||
import wang.yaojia.webterm.session.Telemetry
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
import wang.yaojia.webterm.wire.GateKind
|
||||
import wang.yaojia.webterm.wire.StatusTelemetry
|
||||
import wang.yaojia.webterm.wiring.TerminalSessionController
|
||||
|
||||
/**
|
||||
* # GateViewModel (A22) — the cockpit's remote approve/reject + telemetry + away-digest presenter.
|
||||
*
|
||||
* Folds its OWN [TerminalSessionController.controlEvents] mailbox (gate / telemetry / digest)
|
||||
* into a single [collectAsStateWithLifecycle-friendly][uiState] snapshot, and resolves the user's
|
||||
* decision back onto [TerminalSessionController.decideGate] behind the **two-line epoch stale-guard**
|
||||
* (plan §3.2). Ports the iOS gate-cockpit surface 1:1.
|
||||
*
|
||||
* ### Two-line stale-guard (security-load-bearing, plan §1/§3.2)
|
||||
* A held gate carries an [GateState.epoch] that increments only on a `pending` false→true rising
|
||||
* edge (`GateTracker`). Every decision is tapped against the epoch of the gate that was ON SCREEN.
|
||||
* [decide] drops a decision whose `epoch` no longer matches the current live gate — so a slow tap
|
||||
* against a resolved gate can NEVER approve the NEXT one (防误批新 gate). This is **line 1**; the
|
||||
* engine re-checks `GateTracker.canDecide` on its confinement dispatcher as **line 2**
|
||||
* ([TerminalSessionController.decideGate]). Defense in depth: the UI drop is immediate, the engine
|
||||
* drop is authoritative.
|
||||
*
|
||||
* ### Haptic on ARRIVAL only
|
||||
* [gateArrivals] emits exactly once per NEW gate (a rising edge — a new [GateState.epoch]), never on
|
||||
* a same-epoch refresh and never on a falling edge. A Composable collects it and fires a haptic
|
||||
* gated by `LocalReduceMotion` (the OS reduce-motion setting) — the gate itself is composition-only,
|
||||
* so this VM stays pure/JVM-testable and the actual haptic is device-QA.
|
||||
*
|
||||
* ### Testability
|
||||
* A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time
|
||||
* with no `Dispatchers.Main` / Robolectric. It is OWNED by the config-surviving
|
||||
* [RetainedSessionHolder][wang.yaojia.webterm.wiring.RetainedSessionHolder] (one per session), which
|
||||
* constructs it and calls [bind] on the engine's confined scope BEFORE `controller.start()`; so its
|
||||
* gate/telemetry state survives a rotation instead of being re-derived from an empty fresh mailbox.
|
||||
* [decide] is synchronous.
|
||||
*
|
||||
* ### Its OWN control mailbox (FIX 1 — no split with the reconnect banner)
|
||||
* [controlEvents] is captured ONCE at construction — a dedicated [TerminalSessionController.controlEvents]
|
||||
* mailbox that is INDEPENDENT of the banner's (and the A28 timeline's). Every consumer sees every
|
||||
* control event; none steals from another. Capturing at construction registers the mailbox eagerly,
|
||||
* before the holder calls `controller.start()`, so the first gate is never dropped.
|
||||
*/
|
||||
public class GateViewModel(
|
||||
private val controller: TerminalSessionController,
|
||||
) {
|
||||
/** This VM's OWN control-event mailbox, registered eagerly at construction (before `start()`). */
|
||||
private val controlEvents: Flow<SessionEvent> = controller.controlEvents()
|
||||
|
||||
private val _uiState = MutableStateFlow(GateUiState())
|
||||
|
||||
/** The single snapshot the gate/cockpit surfaces render from. */
|
||||
public val uiState: StateFlow<GateUiState> = _uiState.asStateFlow()
|
||||
|
||||
// replay=0: a haptic is a fire-once effect, not state. DROP_OLDEST so an emit never suspends the
|
||||
// event collector even when no haptic collector is attached (e.g. reduce-motion is on).
|
||||
private val _gateArrivals = MutableSharedFlow<Unit>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** One `Unit` per NEW gate (rising edge). Composable → haptic, gated by `LocalReduceMotion`. */
|
||||
public val gateArrivals: SharedFlow<Unit> = _gateArrivals.asSharedFlow()
|
||||
|
||||
// The epoch we last fired an arrival for; guards "arrival ONCE per new gate" (not per refresh).
|
||||
private var lastArrivalEpoch: Int? = null
|
||||
|
||||
private var bound: Boolean = false
|
||||
|
||||
/**
|
||||
* Start folding this VM's [controlEvents] mailbox into [uiState] / [gateArrivals] in [scope] (the
|
||||
* holder passes the engine's confined scope, so the fold is cancelled with the SESSION, surviving a
|
||||
* config change). Idempotent — a second call is a no-op. The collector completes when the control
|
||||
* stream closes (session teardown) or when [scope] is cancelled.
|
||||
*/
|
||||
public fun bind(scope: CoroutineScope) {
|
||||
if (bound) return
|
||||
bound = true
|
||||
scope.launch {
|
||||
controlEvents.collect(::onEvent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEvent(event: SessionEvent) {
|
||||
when (event) {
|
||||
is Gate -> onGate(event.gate)
|
||||
is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry)
|
||||
is Digest -> onDigest(event.digest)
|
||||
// The session ended: a held gate is no longer decidable — clear it so no stale card lingers.
|
||||
is Exited -> _uiState.value = _uiState.value.copy(gate = null)
|
||||
else -> Unit // Output / Connection / Adopted are not cockpit concerns.
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGate(gate: GateState?) {
|
||||
_uiState.value = _uiState.value.copy(gate = gate)
|
||||
if (gate == null) return // Falling edge: gate lifted; keep lastArrivalEpoch so it can't refire.
|
||||
// Rising edge = a NEW epoch (epochs are monotonic; a refresh keeps the same epoch).
|
||||
if (gate.epoch != lastArrivalEpoch) {
|
||||
lastArrivalEpoch = gate.epoch
|
||||
_gateArrivals.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDigest(digest: AwayDigest) {
|
||||
// All-zero digest is suppressed (the UI's "don't render" signal) — hold null, not EMPTY.
|
||||
_uiState.value = _uiState.value.copy(digest = if (digest.isEmpty) null else digest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user decision behind the two-line stale-guard (line 1). [epoch] is the epoch of the
|
||||
* gate that was ON SCREEN when the button was tapped (captured by the banner/sheet). Drops the
|
||||
* decision — sending nothing — when no gate is held, the epoch is stale, or the decision is not
|
||||
* an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message.
|
||||
*/
|
||||
public fun decide(decision: GateDecision, epoch: Int) {
|
||||
val held = _uiState.value.gate ?: return // canDecide line 1: no gate held → drop.
|
||||
if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop.
|
||||
val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop.
|
||||
controller.decideGate(epoch, message)
|
||||
}
|
||||
}
|
||||
|
||||
/** The three logical gate decisions the surfaces expose (tool = approve/reject; plan = 3-way). */
|
||||
public enum class GateDecision {
|
||||
/** Tool: plain approve (`mode` absent). Plan: approve + review (`mode:"default"`). */
|
||||
APPROVE,
|
||||
|
||||
/** Plan only: approve + auto-accept edits (`mode:"acceptEdits"`). */
|
||||
ACCEPT_EDITS,
|
||||
|
||||
/** Tool: reject. Plan: keep planning (both wire to `reject`). */
|
||||
REJECT,
|
||||
}
|
||||
|
||||
/** The gate/cockpit snapshot rendered by the surfaces. All fields null = nothing to show. */
|
||||
public data class GateUiState(
|
||||
/** The held permission gate, or `null` when none (falling edge / never risen). */
|
||||
val gate: GateState? = null,
|
||||
/** Latest statusLine telemetry, or `null` before the first `telemetry` frame. */
|
||||
val telemetry: StatusTelemetry? = null,
|
||||
/** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */
|
||||
val digest: AwayDigest? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Map a [GateDecision] to the exact wire [ClientMessage] for the current [kind], mirroring
|
||||
* [GateState.Affordance.clientMessage] (public/tabs.ts:345-347). Returns `null` when the decision is
|
||||
* not an affordance of [kind] (e.g. [GateDecision.ACCEPT_EDITS] on a [GateKind.TOOL] gate) — the
|
||||
* caller drops it. Pure — independently unit-tested.
|
||||
*/
|
||||
internal fun resolveGateMessage(decision: GateDecision, kind: GateKind): ClientMessage? =
|
||||
when (kind) {
|
||||
GateKind.TOOL -> when (decision) {
|
||||
GateDecision.APPROVE -> GateState.Affordance.APPROVE.clientMessage
|
||||
GateDecision.REJECT -> GateState.Affordance.REJECT.clientMessage
|
||||
GateDecision.ACCEPT_EDITS -> null // tool gates have no acceptEdits affordance.
|
||||
}
|
||||
|
||||
GateKind.PLAN -> when (decision) {
|
||||
GateDecision.APPROVE -> GateState.Affordance.APPROVE_REVIEW.clientMessage
|
||||
GateDecision.ACCEPT_EDITS -> GateState.Affordance.APPROVE_AUTO.clientMessage
|
||||
GateDecision.REJECT -> GateState.Affordance.KEEP_PLANNING.clientMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.runPairingProbe
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.wire.HostClassifier
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HostNetworkTier
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* # PairingViewModel (A19) — QR/manual URL → confirm-before-network → two-step probe → secure save.
|
||||
*
|
||||
* The pairing state machine, ported 1:1 from the iOS pairing flow, as a pure JVM presenter (mirrors
|
||||
* [GateViewModel] — a plain class with [bind], NOT an `androidx.lifecycle.ViewModel`, so it runs under
|
||||
* `runTest` virtual time with no `Dispatchers.Main`/Robolectric). The Compose/CameraX/ML-Kit/permission
|
||||
* shell ([wang.yaojia.webterm.screens.PairingScreen]) is device-QA (plan §7); THIS is the JVM-tested core.
|
||||
*
|
||||
* ### The four hard rules this class enforces (plan §5.4 / §8)
|
||||
* 1. **One validator.** Every scanned or typed string funnels through [HostEndpoint.fromBaseUrl] — the
|
||||
* single frozen Origin/WS validator (:wire-protocol). A string it rejects never reaches the network.
|
||||
* 2. **Confirm-before-network.** [onManualEntry]/[onQrScanned] only VALIDATE + classify; they never
|
||||
* probe. The network is touched exclusively by [confirm]/[retry]. A scanned URL is never
|
||||
* auto-connected.
|
||||
* 3. **Public host needs an explicit acknowledge.** A [WarningTier.PUBLIC] host cannot be probed until
|
||||
* the user ticks the risk acknowledge ([confirm]`(acknowledgedPublicRisk = true)`); otherwise the
|
||||
* confirm is a no-op that re-arms the reminder.
|
||||
* 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** Both [confirm] and [retry]
|
||||
* funnel through the ONE private [runProbe]; for a [WarningTier.TUNNEL] host with no installed
|
||||
* device cert, [runProbe] refuses BEFORE any network I/O and re-refuses on every retry (plan §8
|
||||
* "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed").
|
||||
*
|
||||
* On probe success the validated host is persisted via [HostStore.upsert].
|
||||
*
|
||||
* @param hostStore where a successfully-paired [Host] is saved.
|
||||
* @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests.
|
||||
* @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO).
|
||||
* @param newId host-id allocator (default random UUID; deterministic in tests).
|
||||
*/
|
||||
public class PairingViewModel(
|
||||
private val hostStore: HostStore,
|
||||
private val prober: PairingProber,
|
||||
private val hasDeviceCert: suspend () -> Boolean,
|
||||
private val newId: () -> String = { UUID.randomUUID().toString() },
|
||||
) {
|
||||
private val _uiState = MutableStateFlow<PairingUiState>(PairingUiState.Entry())
|
||||
|
||||
/** The single snapshot the pairing UI renders from. */
|
||||
public val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
private var probeJob: Job? = null
|
||||
|
||||
/** Bind the scope probes launch into (the screen passes a lifecycle scope). Idempotent-ish: last wins. */
|
||||
public fun bind(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
}
|
||||
|
||||
// ── Input funnels (RULE 1 + RULE 2: validate + classify only, NEVER probe) ──────────────────────
|
||||
|
||||
/** Manual URL entry submit. Validates through the one validator; on success shows the confirm gate. */
|
||||
public fun onManualEntry(rawUrl: String) {
|
||||
presentCandidate(rawUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* A decoded QR payload. IDENTICAL funnel to [onManualEntry] — a scanned string is untrusted external
|
||||
* input and is NEVER auto-connected (RULE 2); it only advances to the confirm gate.
|
||||
*/
|
||||
public fun onQrScanned(rawUrl: String) {
|
||||
presentCandidate(rawUrl)
|
||||
}
|
||||
|
||||
private fun presentCandidate(rawUrl: String) {
|
||||
val endpoint = HostEndpoint.fromBaseUrl(rawUrl)
|
||||
if (endpoint == null) {
|
||||
_uiState.value = PairingUiState.Entry(error = EntryError.INVALID_URL)
|
||||
return
|
||||
}
|
||||
_uiState.value = PairingUiState.Confirming(
|
||||
endpoint = endpoint,
|
||||
name = defaultName(endpoint),
|
||||
tier = PairingTiers.tierFor(endpoint),
|
||||
)
|
||||
}
|
||||
|
||||
/** Edit the host display name on the confirm screen (no effect off the confirm state). */
|
||||
public fun setName(name: String) {
|
||||
val state = _uiState.value
|
||||
if (state is PairingUiState.Confirming) _uiState.value = state.copy(name = name)
|
||||
}
|
||||
|
||||
/** Discard the current candidate/result and return to the entry field. */
|
||||
public fun reset() {
|
||||
probeJob?.cancel()
|
||||
_uiState.value = PairingUiState.Entry()
|
||||
}
|
||||
|
||||
// ── The network choke point (RULE 3 + RULE 4) ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Confirm the on-screen candidate and probe it. For a public host, [acknowledgedPublicRisk] MUST be
|
||||
* true or this re-arms the acknowledge reminder without touching the network (RULE 3).
|
||||
*/
|
||||
public fun confirm(acknowledgedPublicRisk: Boolean = false) {
|
||||
val candidate = _uiState.value.candidate() ?: return
|
||||
runProbe(candidate, acknowledgedPublicRisk)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry after a failure or a cert-gate refusal. Funnels through the SAME [runProbe] choke point as
|
||||
* [confirm] — so the tunnel cert-gate (RULE 4) cannot be bypassed by retrying.
|
||||
*/
|
||||
public fun retry(acknowledgedPublicRisk: Boolean = false) {
|
||||
val candidate = _uiState.value.candidate() ?: return
|
||||
runProbe(candidate, acknowledgedPublicRisk)
|
||||
}
|
||||
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
|
||||
val tier = candidate.tier
|
||||
// RULE 3 — public host: no probe until the risk is explicitly acknowledged.
|
||||
if (tier.needsExplicitAck && !acknowledgedPublicRisk) {
|
||||
_uiState.value = PairingUiState.Confirming(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = tier,
|
||||
awaitingAck = true,
|
||||
)
|
||||
return
|
||||
}
|
||||
val scope = scope ?: return
|
||||
probeJob?.cancel()
|
||||
probeJob = scope.launch {
|
||||
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry re-hits this same guard.
|
||||
if (tier.isCertGated && !hasDeviceCert()) {
|
||||
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
|
||||
return@launch
|
||||
}
|
||||
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
||||
when (val result = prober.probe(candidate.endpoint)) {
|
||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||
is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = tier,
|
||||
error = result.error,
|
||||
message = PairingCopy.describe(result.error, tier),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onProbeSuccess(candidate: Candidate) {
|
||||
val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl)
|
||||
if (host == null) {
|
||||
// Unreachable in practice (the endpoint already validated), but never persist a null host.
|
||||
_uiState.value = PairingUiState.Failed(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = candidate.tier,
|
||||
error = PairingError.HttpOkButNotWebTerminal,
|
||||
message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier),
|
||||
)
|
||||
return
|
||||
}
|
||||
hostStore.upsert(host)
|
||||
_uiState.value = PairingUiState.Paired(host)
|
||||
}
|
||||
|
||||
private fun defaultName(endpoint: HostEndpoint): String =
|
||||
HostClassifier.hostOf(endpoint).ifEmpty { endpoint.baseUrl }
|
||||
}
|
||||
|
||||
/** The two-step pairing probe seam ([wang.yaojia.webterm.api.pairing.runPairingProbe]); faked in tests. */
|
||||
public fun interface PairingProber {
|
||||
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared
|
||||
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
|
||||
* layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel —
|
||||
* `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink
|
||||
* I/O on a UI thread.
|
||||
*/
|
||||
public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) }
|
||||
|
||||
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pairing warning tiers the UI reproduces (plan §5.4 / §8). The four [HostNetworkTier] network
|
||||
* classes plus [TUNNEL] — the native tunnel domain (`*.terminal.yaojia.wang`), which classifies as
|
||||
* [HostNetworkTier.PUBLIC] on the wire but is cert-gated rather than ack-gated (its warning is softened
|
||||
* because the device cert IS the gate).
|
||||
*/
|
||||
public enum class WarningTier {
|
||||
/** localhost / 127.0.0.0/8 / ::1 — no warning. */
|
||||
LOOPBACK,
|
||||
|
||||
/** RFC1918 / link-local / mDNS — non-blocking `ws://` cleartext notice. */
|
||||
PRIVATE_LAN,
|
||||
|
||||
/** Tailscale CGNAT / MagicDNS — WireGuard-encrypted; no cleartext warning. */
|
||||
TAILSCALE,
|
||||
|
||||
/** Native tunnel `*.terminal.yaojia.wang` — cert-gated; warning softened, TLS failure = cert invalid. */
|
||||
TUNNEL,
|
||||
|
||||
/** Everything else (incl. malformed — fail-safe) — strongest blocking warning, explicit ack required. */
|
||||
PUBLIC,
|
||||
}
|
||||
|
||||
/** Pure tier classification + gate policy (independently unit-tested; plan §5.4). */
|
||||
public object PairingTiers {
|
||||
/** The native-tunnel host suffix — a subdomain of this presents a real LE cert but is mTLS-gated. */
|
||||
private const val TUNNEL_SUFFIX: String = ".terminal.yaojia.wang"
|
||||
|
||||
/**
|
||||
* Classify [endpoint] into a [WarningTier]. The tunnel domain is detected FIRST (it would otherwise
|
||||
* fall to [WarningTier.PUBLIC]); everything else defers to the frozen [HostClassifier]. Fail-safe:
|
||||
* an unclassifiable host is [WarningTier.PUBLIC].
|
||||
*/
|
||||
public fun tierFor(endpoint: HostEndpoint): WarningTier {
|
||||
val host = HostClassifier.hostOf(endpoint).lowercase()
|
||||
if (host.endsWith(TUNNEL_SUFFIX)) return WarningTier.TUNNEL
|
||||
return when (HostClassifier.classify(endpoint)) {
|
||||
HostNetworkTier.LOOPBACK -> WarningTier.LOOPBACK
|
||||
HostNetworkTier.PRIVATE_LAN -> WarningTier.PRIVATE_LAN
|
||||
HostNetworkTier.TAILSCALE -> WarningTier.TAILSCALE
|
||||
HostNetworkTier.PUBLIC -> WarningTier.PUBLIC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Only a public host demands the explicit risk acknowledge before probing (RULE 3). */
|
||||
public val WarningTier.needsExplicitAck: Boolean get() = this == WarningTier.PUBLIC
|
||||
|
||||
/** Only the native-tunnel host is device-cert-gated (RULE 4). */
|
||||
public val WarningTier.isCertGated: Boolean get() = this == WarningTier.TUNNEL
|
||||
|
||||
// ── UI state ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The pairing screen snapshot. */
|
||||
public sealed interface PairingUiState {
|
||||
/** The URL/QR entry field, optionally showing a validation [error]. */
|
||||
public data class Entry(val error: EntryError? = null) : PairingUiState
|
||||
|
||||
/**
|
||||
* A validated candidate awaiting the user's explicit confirm (RULE 2). [awaitingAck] is set after a
|
||||
* public-host confirm without acknowledgement (RULE 3) so the UI can highlight the risk toggle.
|
||||
*/
|
||||
public data class Confirming(
|
||||
val endpoint: HostEndpoint,
|
||||
val name: String,
|
||||
val tier: WarningTier,
|
||||
val awaitingAck: Boolean = false,
|
||||
) : PairingUiState
|
||||
|
||||
/** The two-step probe is running against [endpoint]. */
|
||||
public data class Probing(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState
|
||||
|
||||
/**
|
||||
* A tunnel host was refused for lack of a device cert (RULE 4). The user must import a cert (A27)
|
||||
* then [PairingViewModel.retry] — which re-hits the same gate.
|
||||
*/
|
||||
public data class CertRequired(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState
|
||||
|
||||
/** The probe failed; [message] is inert, app-authored copy for [error]. Retryable. */
|
||||
public data class Failed(
|
||||
val endpoint: HostEndpoint,
|
||||
val name: String,
|
||||
val tier: WarningTier,
|
||||
val error: PairingError,
|
||||
val message: String,
|
||||
) : PairingUiState
|
||||
|
||||
/** Paired + persisted. The screen navigates onward from here. */
|
||||
public data class Paired(val host: Host) : PairingUiState
|
||||
}
|
||||
|
||||
/** Client-side validation failures on the entry field. */
|
||||
public enum class EntryError {
|
||||
/** The typed/scanned string is not a valid `http(s)` URL with a host. */
|
||||
INVALID_URL,
|
||||
}
|
||||
|
||||
/** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */
|
||||
private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier)
|
||||
|
||||
private fun PairingUiState.candidate(): Candidate? = when (this) {
|
||||
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.CertRequired -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.Failed -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.Entry, is PairingUiState.Paired -> null
|
||||
}
|
||||
|
||||
/** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */
|
||||
internal object PairingCopy {
|
||||
fun describe(error: PairingError, tier: WarningTier): String = when (error) {
|
||||
is PairingError.HostUnreachable ->
|
||||
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
|
||||
PairingError.HttpOkButNotWebTerminal ->
|
||||
"这个端口在运行别的服务,不是 web-terminal。端口对吗?"
|
||||
is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived
|
||||
is PairingError.CleartextBlocked ->
|
||||
"系统阻止了到 ${error.host} 的明文连接。仅局域网 / Tailscale 地址允许 ws:// 明文。"
|
||||
PairingError.TlsFailure ->
|
||||
// Tunnel hosts present a real LE server cert, so a TLS failure there means OUR client cert
|
||||
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
|
||||
if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。"
|
||||
else "TLS 握手失败:无法验证服务器证书。"
|
||||
PairingError.Timeout ->
|
||||
"连接超时。主机可能不可达,或响应过慢。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
|
||||
/**
|
||||
* # ProjectDetailViewModel (A23) — one project's detail (`GET /projects/detail?path=`), a phase state
|
||||
* machine (same discipline as [DiffViewModel]/iOS `ProjectDetailViewModel`).
|
||||
*
|
||||
* The [fetch] closure is injected — production wraps [ProjectsGateway.projectDetail] (the builder's
|
||||
* percent-encoding + 400/404/500 → typed [ApiClientError] mapping lives in `:api-client`), tests inject a
|
||||
* fake. This VM only reduces the three user-visible outcomes:
|
||||
* - success → [Phase.Loaded] (sessions/worktrees/hasClaudeMd/claudeMd passed through, rendered INERT);
|
||||
* - 400 / [ApiClientError.InvalidRequest] → [Failure.PATH_INVALID];
|
||||
* - 404 → [Failure.NOT_FOUND]; 500 / decode / transport → [Failure.UNAVAILABLE] — all retryable via [load].
|
||||
*
|
||||
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest` with no
|
||||
* `Dispatchers.Main`. The screen calls [load] in a lifecycle scope; the retry action re-calls it.
|
||||
*/
|
||||
public class ProjectDetailViewModel(
|
||||
public val path: String,
|
||||
private val fetch: suspend () -> ProjectDetail,
|
||||
) {
|
||||
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
|
||||
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
|
||||
|
||||
/** The load phase the screen renders (spinner / detail / error+retry). */
|
||||
public sealed interface Phase {
|
||||
public data object Loading : Phase
|
||||
public data class Loaded(val detail: ProjectDetail) : Phase
|
||||
public data class Failed(val failure: Failure) : Phase
|
||||
}
|
||||
|
||||
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
|
||||
|
||||
/** The single snapshot `ProjectDetailScreen` renders from. */
|
||||
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||
|
||||
/** Fetch and present. Also the retry path: callable again after a [Phase.Failed]. */
|
||||
public suspend fun load() {
|
||||
_phase.value = Phase.Loading
|
||||
_phase.value = try {
|
||||
Phase.Loaded(fetch())
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: ApiClientError) {
|
||||
Phase.Failed(failureFor(error))
|
||||
} catch (_: Throwable) {
|
||||
// Transport/decode etc. — a retryable catch-all.
|
||||
Phase.Failed(Failure.UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun failureFor(error: ApiClientError): Failure = when (error) {
|
||||
ApiClientError.ProjectPathInvalid, ApiClientError.InvalidRequest -> Failure.PATH_INVALID
|
||||
ApiClientError.ProjectNotFound -> Failure.NOT_FOUND
|
||||
else -> Failure.UNAVAILABLE
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/** Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). */
|
||||
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel =
|
||||
ProjectDetailViewModel(path) { gateway.projectDetail(path) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
import wang.yaojia.webterm.wire.Validation
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* # ProjectsViewModel (A23) — the Projects list state (mirrors web v0.6 `public/projects.ts` +
|
||||
* iOS `ProjectsViewModel`): the grouped grid over `GET /projects`, the cross-device favourites/collapse
|
||||
* round-trip over `GET/PUT /prefs`, and the "open Claude here" navigation signal.
|
||||
*
|
||||
* ### `/prefs` is a clobber-sensitive surface (R11 · plan §4.3)
|
||||
* `PUT /prefs` replaces the WHOLE blob server-side, so every write goes through [UiPrefs]'s
|
||||
* unknown-key-preserving API ([UiPrefs.withFavourites]/[UiPrefs.withCollapsed]) — any top-level key a
|
||||
* web/iOS/future-server client wrote is carried back verbatim. If the `/prefs` GET never succeeded
|
||||
* ([prefsBase] is null), a favourite/collapse toggle changes ONLY local state and is **never PUT** (an
|
||||
* empty-base PUT would wipe the server's favourites). A successful PUT adopts the server's sanitized
|
||||
* echo as the new truth. Prefs load ONCE (mirrors web `mountProjects.init`); the refresh cadence
|
||||
* re-fetches only projects so it never clobbers unsynced local edits.
|
||||
*
|
||||
* ### Untrusted server boundary (plan §8)
|
||||
* The list decodes tolerantly inside [ApiClient]; this VM never crashes on a partial list. Every server
|
||||
* string (name/path/branch) is carried INERT to the screen (plain `Text`, no linkify/markdown). A cwd
|
||||
* is server data → re-validated through [Validation.isAbsoluteCwd] before it is minted into an open
|
||||
* request (the same discipline the deep-link router uses).
|
||||
*
|
||||
* ### A plain presenter (JVM-testable, mirrors [SessionListViewModel]/[DiffViewModel])
|
||||
* Not an `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no
|
||||
* `Dispatchers.Main`. Its collaborator is the [ProjectsGateway] seam, so the grouping-parity,
|
||||
* favourite/collapse and prefs-round-trip logic are all JVM-tested; grid/sheet layout is device-QA.
|
||||
*/
|
||||
public class ProjectsViewModel(
|
||||
private val gateway: ProjectsGateway,
|
||||
) {
|
||||
private val _uiState = MutableStateFlow(ProjectsUiState())
|
||||
|
||||
/** The single snapshot `ProjectsScreen` renders from. */
|
||||
public val uiState: StateFlow<ProjectsUiState> = _uiState.asStateFlow()
|
||||
|
||||
/**
|
||||
* The last known FULL prefs blob (known + unknown keys). `null` = never successfully loaded →
|
||||
* NEVER PUT (R11: an empty-base write would clobber the server's favourites).
|
||||
*/
|
||||
private var prefsBase: UiPrefs? = null
|
||||
|
||||
/** Live search-box binding — re-derives [ProjectsUiState.groups] on the next render. */
|
||||
public fun onSearchChange(text: String) {
|
||||
_uiState.update { it.copy(searchText = text) }
|
||||
}
|
||||
|
||||
/** First entry: prefs (once) + projects. Prefs stay in memory afterward (mirrors web `init`). */
|
||||
public suspend fun load() {
|
||||
loadPrefsIfNeeded()
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Re-fetch only projects (pull-to-refresh / retry). A failure keeps the old list + an error line. */
|
||||
public suspend fun refresh() {
|
||||
try {
|
||||
val projects = gateway.projects()
|
||||
_uiState.update { it.copy(projects = projects, fetchError = null, hasLoadedOnce = true) }
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
_uiState.update { it.copy(fetchError = ProjectsCopy.fetchFailed(errorDetail(error))) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPrefsIfNeeded() {
|
||||
if (prefsBase != null) return
|
||||
try {
|
||||
adopt(gateway.prefs())
|
||||
_uiState.update { it.copy(prefsError = null) }
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
_uiState.update { it.copy(prefsError = ProjectsCopy.PREFS_LOAD_FAILED) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Favourites / collapse (cross-device prefs round-trip) ────────────────────────────────────
|
||||
|
||||
public suspend fun toggleFavourite(path: String) {
|
||||
val current = _uiState.value.favourites
|
||||
val next = if (current.contains(path)) current.filterNot { it == path } else current + path
|
||||
_uiState.update { it.copy(favourites = next) }
|
||||
persistPrefs()
|
||||
}
|
||||
|
||||
public suspend fun toggleCollapsed(key: String) {
|
||||
val current = _uiState.value.collapsedGroups
|
||||
// Store only the "collapsed" fact; expanded is the default (matches web/server sanitizers).
|
||||
val next = if (current[key] == true) current.filterKeys { it != key } else current + (key to true)
|
||||
_uiState.update { it.copy(collapsedGroups = next) }
|
||||
persistPrefs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the two known keys on the last-known full blob (unknown keys carried through), PUT, then
|
||||
* adopt the server echo. No base (prefs never loaded) → local-only, never PUT (R11).
|
||||
*/
|
||||
private suspend fun persistPrefs() {
|
||||
val base = prefsBase ?: return
|
||||
val next = base
|
||||
.withFavourites(_uiState.value.favourites)
|
||||
.withCollapsed(_uiState.value.collapsedGroups)
|
||||
prefsBase = next // optimistic: stack consecutive toggles on the same base
|
||||
try {
|
||||
adopt(gateway.putPrefs(next))
|
||||
_uiState.update { it.copy(prefsSyncError = null) }
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
_uiState.update { it.copy(prefsSyncError = ProjectsCopy.prefsSyncFailed(errorDetail(error))) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun adopt(prefs: UiPrefs) {
|
||||
prefsBase = prefs
|
||||
_uiState.update { it.copy(favourites = prefs.favourites, collapsedGroups = prefs.collapsed) }
|
||||
}
|
||||
|
||||
// ── Open Claude here (the core action — attach(null, cwd=projectPath)) ────────────────────────
|
||||
|
||||
/**
|
||||
* Mint an [ProjectOpenRequest] for a fresh session in [cwd] (`attach(null, cwd)` + a `claude\r`
|
||||
* bootstrap). The path is server data → validated first; an invalid one surfaces an inert error and
|
||||
* mints nothing. A fresh [UUID] per request re-triggers `onChange` even for a repeated tap.
|
||||
*/
|
||||
public fun requestOpenClaude(cwd: String) {
|
||||
if (!Validation.isAbsoluteCwd(cwd)) {
|
||||
_uiState.update { it.copy(openError = ProjectsCopy.OPEN_CLAUDE_INVALID_PATH) }
|
||||
return
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
openError = null,
|
||||
openRequest = ProjectOpenRequest(
|
||||
id = UUID.randomUUID(),
|
||||
cwd = cwd,
|
||||
bootstrapInput = ProjectsCopy.CLAUDE_BOOTSTRAP_INPUT,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The nav layer clears the signal once it has routed the attach (so it does not re-fire). */
|
||||
public fun consumeOpenRequest() {
|
||||
_uiState.update { it.copy(openRequest = null) }
|
||||
}
|
||||
|
||||
// ── Detail assembly (the detail-screen seam) ─────────────────────────────────────────────────
|
||||
|
||||
public fun makeDetailViewModel(path: String): ProjectDetailViewModel =
|
||||
ProjectDetailViewModel.forGateway(gateway, path)
|
||||
|
||||
private fun errorDetail(error: Throwable): String =
|
||||
(error as? ApiClientError)?.userMessage ?: error.message ?: error.toString()
|
||||
}
|
||||
|
||||
// ── ProjectGrouping — the byte-parity grouping core (mirrors public/projects.ts + iOS) ───────────
|
||||
|
||||
/**
|
||||
* Pure namespace grouping — a LINE-FOR-LINE port of web v0.6 `public/projects.ts`
|
||||
* (`filterProjects`/`sortProjects`/`groupProjects`/`displayLabel`) so phone, iPad and web see the SAME
|
||||
* sections.
|
||||
*
|
||||
* **The frozen contract: group [ProjectGroup.key]s are byte-identical to web/iOS.** Collapse state is
|
||||
* persisted BY group key into the cross-device `/prefs.collapsed` blob, so a key drift = two ends losing
|
||||
* each other's collapse state. The three key shapes are frozen:
|
||||
* - the sentinels [ACTIVE_GROUP_KEY] `" active"` / [OTHER_GROUP_KEY] `" other"` (leading space → can't
|
||||
* collide with a real `First.Second` namespace);
|
||||
* - a namespace group's key is its **first-seen casing** (`"Acme.Web"`, not the lower-cased bucket key).
|
||||
* [ProjectGroup.label] is local UI text (Chinese sentinels; namespace label = the key itself) — only the
|
||||
* KEY is frozen, so labels may diverge per platform without breaking collapse sync.
|
||||
*/
|
||||
public object ProjectGrouping {
|
||||
/** Sentinel keys (leading space, mirrors public/projects.ts:83-84). Frozen — cross-device shared. */
|
||||
public const val ACTIVE_GROUP_KEY: String = " active"
|
||||
public const val OTHER_GROUP_KEY: String = " other"
|
||||
|
||||
/** A namespace needs at least this many members to earn its own section (web `MIN_GROUP_SIZE`). */
|
||||
private const val MIN_GROUP_SIZE = 2
|
||||
|
||||
/** Filter by name/path substring, case-insensitive (mirrors `filterProjects`). Never mutates input. */
|
||||
public fun filter(projects: List<ProjectInfo>, query: String): List<ProjectInfo> {
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.isEmpty()) return projects
|
||||
val lower = trimmed.lowercase()
|
||||
return projects.filter {
|
||||
it.name.lowercase().contains(lower) || it.path.lowercase().contains(lower)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Favourites-first, then `lastActiveMs` descending (mirrors `sortProjects`). Kotlin `sortedWith` is
|
||||
* stable, so ties keep server order — matching JS's stable `Array.sort` byte-for-byte.
|
||||
*/
|
||||
public fun sort(projects: List<ProjectInfo>, favourites: Set<String>): List<ProjectInfo> =
|
||||
projects.sortedWith(
|
||||
compareBy<ProjectInfo> { if (favourites.contains(it.path)) 0 else 1 }
|
||||
.thenByDescending { it.lastActiveMs ?: 0L },
|
||||
)
|
||||
|
||||
/**
|
||||
* The card label inside a namespace group: the tail after the `<key>.` prefix (case-insensitive), so
|
||||
* `Billo.Platform.` isn't shouted on every card. Sentinel groups keep the full name (mirrors
|
||||
* `displayLabel`).
|
||||
*/
|
||||
public fun displayLabel(name: String, groupKey: String): String {
|
||||
if (groupKey == ACTIVE_GROUP_KEY || groupKey == OTHER_GROUP_KEY) return name
|
||||
val prefix = "$groupKey."
|
||||
return if (name.lowercase().startsWith(prefix.lowercase())) name.substring(prefix.length) else name
|
||||
}
|
||||
|
||||
/** Running = any non-exited session (same predicate as web `hasRunningSession`). */
|
||||
public fun hasRunningSession(project: ProjectInfo): Boolean = project.sessions.any { !it.exited }
|
||||
|
||||
/**
|
||||
* Group into collapsible namespace sections (mirrors `groupProjects`). Running projects duplicate
|
||||
* into a pinned "Active now"; sub-[MIN_GROUP_SIZE] namespaces fall into "Other"; no real namespace
|
||||
* groups → a single header-less `flat` group (keyed [OTHER_GROUP_KEY]). Never mutates input.
|
||||
*/
|
||||
public fun group(projects: List<ProjectInfo>, favourites: Set<String>): List<ProjectGroup> {
|
||||
val (namespaceGroups, other) = bucketByNamespace(projects, favourites)
|
||||
|
||||
// Grouping bought nothing — one flat, header-less grid (web's `flat` fallback).
|
||||
if (namespaceGroups.isEmpty()) {
|
||||
return listOf(
|
||||
makeGroup(OTHER_GROUP_KEY, ProjectsCopy.ALL_GROUP_LABEL, ProjectGroupKind.FLAT, projects, favourites),
|
||||
)
|
||||
}
|
||||
|
||||
val orderedNamespaces = namespaceGroups.sortedWith(
|
||||
compareByDescending<ProjectGroup> { maxLastActive(it.projects) }.thenBy { it.label },
|
||||
)
|
||||
|
||||
val groups = ArrayList<ProjectGroup>()
|
||||
val active = projects.filter(::hasRunningSession)
|
||||
if (active.isNotEmpty()) {
|
||||
groups.add(makeGroup(ACTIVE_GROUP_KEY, ProjectsCopy.ACTIVE_GROUP_LABEL, ProjectGroupKind.ACTIVE, active, favourites))
|
||||
}
|
||||
groups.addAll(orderedNamespaces)
|
||||
if (other.isNotEmpty()) {
|
||||
groups.add(makeGroup(OTHER_GROUP_KEY, ProjectsCopy.OTHER_GROUP_LABEL, ProjectGroupKind.OTHER, other, favourites))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket by lower-cased namespace key (de-dup), keeping the first-seen casing as the display/key and
|
||||
* first-seen insertion order (mirrors JS `Map` iteration). Sub-[MIN_GROUP_SIZE] buckets collapse into
|
||||
* "Other". Returns (namespace groups in insertion order, the "other" pile).
|
||||
*/
|
||||
private fun bucketByNamespace(
|
||||
projects: List<ProjectInfo>,
|
||||
favourites: Set<String>,
|
||||
): Pair<List<ProjectGroup>, List<ProjectInfo>> {
|
||||
val bucketOrder = ArrayList<String>()
|
||||
val buckets = HashMap<String, Bucket>()
|
||||
val other = ArrayList<ProjectInfo>()
|
||||
for (project in projects) {
|
||||
val namespace = namespaceKey(project.name)
|
||||
if (namespace == null) {
|
||||
other.add(project)
|
||||
continue
|
||||
}
|
||||
val lowerKey = namespace.lowercase()
|
||||
val existing = buckets[lowerKey]
|
||||
if (existing != null) {
|
||||
existing.items.add(project)
|
||||
} else {
|
||||
buckets[lowerKey] = Bucket(namespace, arrayListOf(project))
|
||||
bucketOrder.add(lowerKey)
|
||||
}
|
||||
}
|
||||
|
||||
val groups = ArrayList<ProjectGroup>()
|
||||
for (lowerKey in bucketOrder) {
|
||||
val bucket = buckets[lowerKey] ?: continue
|
||||
if (bucket.items.size < MIN_GROUP_SIZE) {
|
||||
other.addAll(bucket.items)
|
||||
} else {
|
||||
groups.add(makeGroup(bucket.display, bucket.display, ProjectGroupKind.NAMESPACE, bucket.items, favourites))
|
||||
}
|
||||
}
|
||||
return groups to other
|
||||
}
|
||||
|
||||
/** First two dot-segments (`'a.b.c'` → `'a.b'`; < 2 segments → null). Empty segments kept (JS `split`). */
|
||||
private fun namespaceKey(name: String): String? {
|
||||
val segments = name.split(".")
|
||||
if (segments.size < 2) return null
|
||||
return segments.take(2).joinToString(".")
|
||||
}
|
||||
|
||||
private fun maxLastActive(items: List<ProjectInfo>): Long =
|
||||
items.fold(0L) { acc, p -> maxOf(acc, p.lastActiveMs ?: 0L) }
|
||||
|
||||
private fun makeGroup(
|
||||
key: String,
|
||||
label: String,
|
||||
kind: ProjectGroupKind,
|
||||
items: List<ProjectInfo>,
|
||||
favourites: Set<String>,
|
||||
): ProjectGroup = ProjectGroup(
|
||||
key = key,
|
||||
label = label,
|
||||
kind = kind,
|
||||
projects = sort(items, favourites),
|
||||
activeCount = items.count(::hasRunningSession),
|
||||
)
|
||||
|
||||
private class Bucket(val display: String, val items: MutableList<ProjectInfo>)
|
||||
}
|
||||
|
||||
/** Section kind (mirrors web `ProjectGroupKind`). */
|
||||
public enum class ProjectGroupKind { ACTIVE, NAMESPACE, OTHER, FLAT }
|
||||
|
||||
/** One collapsible section snapshot (mirrors web `ProjectGroup`). [key] is the frozen cross-device id. */
|
||||
public data class ProjectGroup(
|
||||
/** Collapse-state persistence key — byte-identical to web/iOS (`/prefs` shared). */
|
||||
val key: String,
|
||||
val label: String,
|
||||
val kind: ProjectGroupKind,
|
||||
/** Already sorted (favourites-first → recency). */
|
||||
val projects: List<ProjectInfo>,
|
||||
/** Projects with a running session (a collapsed section never silently buries active work). */
|
||||
val activeCount: Int,
|
||||
) {
|
||||
/** "Active now" is always shown; `flat` has no chrome — only namespace/other collapse. */
|
||||
val isCollapsible: Boolean get() = kind == ProjectGroupKind.NAMESPACE || kind == ProjectGroupKind.OTHER
|
||||
}
|
||||
|
||||
/** "Open Claude here" nav signal (attach(null, cwd) + a bootstrap input). Consumed by the nav layer. */
|
||||
public data class ProjectOpenRequest(
|
||||
val id: UUID,
|
||||
/** New session working directory (already validated as an absolute path). */
|
||||
val cwd: String,
|
||||
/** First input injected after attach (`null` = plain shell). */
|
||||
val bootstrapInput: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* The immutable Projects snapshot. Raw inputs ([projects]/[favourites]/[collapsedGroups]/[searchText])
|
||||
* are stored; [groups] derives the byte-parity grouping on demand (mirrors iOS's computed `groups`).
|
||||
*/
|
||||
public data class ProjectsUiState(
|
||||
val searchText: String = "",
|
||||
val hasLoadedOnce: Boolean = false,
|
||||
/** Last project refresh failed (old list kept — one dropped poll never clears the screen). */
|
||||
val fetchError: String? = null,
|
||||
/** Prefs load failed: favourites/collapse are local-only this session, never written back. */
|
||||
val prefsError: String? = null,
|
||||
/** Last `PUT /prefs` failed (the local change is kept; the next toggle retries). */
|
||||
val prefsSyncError: String? = null,
|
||||
/** "Open Claude here" rejected (invalid path). */
|
||||
val openError: String? = null,
|
||||
/** Favourited project paths (order preserved: a new favourite appends). */
|
||||
val favourites: List<String> = emptyList(),
|
||||
/** Group key → collapsed (only `true` stored; expanded is the default). */
|
||||
val collapsedGroups: Map<String, Boolean> = emptyMap(),
|
||||
val projects: List<ProjectInfo> = emptyList(),
|
||||
val openRequest: ProjectOpenRequest? = null,
|
||||
) {
|
||||
/** Search-filtered → web/iOS-identical grouping (favourites-first sort happens inside each group). */
|
||||
val groups: List<ProjectGroup>
|
||||
get() = ProjectGrouping.group(ProjectGrouping.filter(projects, searchText), favourites.toSet())
|
||||
|
||||
val isSearching: Boolean get() = searchText.trim().isNotEmpty()
|
||||
|
||||
/** Searching force-expands every matching section (results never hide behind a collapsed caret). */
|
||||
public fun isCollapsed(group: ProjectGroup): Boolean =
|
||||
group.isCollapsible && !isSearching && collapsedGroups[group.key] == true
|
||||
|
||||
public fun isFavourite(path: String): Boolean = favourites.contains(path)
|
||||
|
||||
/** Empty-state copy: no projects vs no search match (mirrors web `renderGrid`'s two messages). */
|
||||
val emptyStateMessage: String?
|
||||
get() {
|
||||
if (!hasLoadedOnce || fetchError != null) return null
|
||||
if (projects.isEmpty()) return ProjectsCopy.EMPTY_NO_PROJECTS
|
||||
if (ProjectGrouping.filter(projects, searchText).isEmpty()) return ProjectsCopy.EMPTY_NO_MATCH
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ─────────────────────
|
||||
|
||||
/** Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses. */
|
||||
public interface ProjectsGateway {
|
||||
public suspend fun projects(): List<ProjectInfo>
|
||||
public suspend fun prefs(): UiPrefs
|
||||
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
|
||||
public suspend fun projectDetail(path: String): ProjectDetail
|
||||
}
|
||||
|
||||
/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
|
||||
public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGateway {
|
||||
override suspend fun projects(): List<ProjectInfo> = api.projects()
|
||||
override suspend fun prefs(): UiPrefs = api.prefs()
|
||||
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
|
||||
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
|
||||
}
|
||||
|
||||
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */
|
||||
public object ProjectsCopy {
|
||||
public const val TITLE: String = "项目"
|
||||
public const val SEARCH_PROMPT: String = "筛选项目…"
|
||||
public const val ACTIVE_GROUP_LABEL: String = "活跃中"
|
||||
public const val OTHER_GROUP_LABEL: String = "其他"
|
||||
public const val ALL_GROUP_LABEL: String = "全部项目"
|
||||
public const val DIRTY_BADGE: String = "未提交"
|
||||
public const val OPEN_CLAUDE_LABEL: String = "Claude"
|
||||
public const val EMPTY_NO_PROJECTS: String = "未发现项目。请检查主机的 PROJECT_ROOTS 配置。"
|
||||
public const val EMPTY_NO_MATCH: String = "没有匹配的项目。"
|
||||
public const val PREFS_LOAD_FAILED: String = "云端收藏/折叠状态加载失败,本次修改仅在本机生效。"
|
||||
public const val OPEN_CLAUDE_INVALID_PATH: String = "项目路径无效,无法开新会话。"
|
||||
|
||||
/** Enter is `\r` (0x0D), not `\n` — the classic synthesize-input gotcha (CLAUDE.md Gotchas). */
|
||||
public const val CLAUDE_BOOTSTRAP_INPUT: String = "claude\r"
|
||||
|
||||
public fun fetchFailed(detail: String): String = "刷新项目列表失败:$detail"
|
||||
public fun prefsSyncFailed(detail: String): String = "收藏/折叠状态同步失败:$detail"
|
||||
public fun activeCountBadge(count: Int): String = "● $count 活跃"
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
import wang.yaojia.webterm.designsystem.DisplayStatus
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.session.TitleSanitizer
|
||||
import wang.yaojia.webterm.session.UnreadLedger
|
||||
import wang.yaojia.webterm.wire.Tunables
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* # SessionListViewModel (A20) — the session chooser + dashboard + host-menu presenter.
|
||||
*
|
||||
* Folds `GET /live-sessions` for the ACTIVE host into a single [collectAsStateWithLifecycle-friendly]
|
||||
* [uiState] snapshot of [SessionRow]s (status badge · telemetry · thumbnail key · cols×rows · sanitized
|
||||
* title · unread dot), and owns the four list actions: STARTED-scoped [poll], [refresh]
|
||||
* (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open) and [killSession]
|
||||
* (optimistic swipe-to-kill). Ports the iOS session-list surface.
|
||||
*
|
||||
* ### Untrusted server boundary (plan §4 / §8)
|
||||
* The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` →
|
||||
* [wang.yaojia.webterm.wire.ClaudeStatus.UNKNOWN]); this VM never crashes on a partial list. Every
|
||||
* server string ([SessionRow.title] via [TitleSanitizer], cwd, telemetry) is carried INERT for the row
|
||||
* to render as plain `Text` — no linkify/markdown (§8).
|
||||
*
|
||||
* ### STARTED-scoped poll (plan §6.6)
|
||||
* [poll] is a `suspend` loop the screen runs inside `repeatOnLifecycle(STARTED)`: it fetches, waits
|
||||
* [pollInterval] ([Tunables.LIST_POLL_INTERVAL]), repeats — and is cancelled the instant the app leaves
|
||||
* the foreground (no background polling). One-shot actions ([refresh]/[selectHost]/[markSeen]/
|
||||
* [killSession]) are `suspend` too, so the screen launches each in its own scope; a [fetchMutex]
|
||||
* serialises the network reads so a manual refresh and a poll tick never interleave-corrupt [uiState].
|
||||
*
|
||||
* ### Testability
|
||||
* A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time with
|
||||
* no `Dispatchers.Main`/Robolectric. Its collaborators are seams ([SessionListGatewayFactory],
|
||||
* [UnreadWatermarkStore]) so the fetch→row mapping, unread-dot logic and optimistic-kill path are all
|
||||
* JVM-tested; swipe/thumbnail/pull gestures are device-QA (plan §7).
|
||||
*/
|
||||
public class SessionListViewModel(
|
||||
private val hostStore: HostStore,
|
||||
private val gatewayFactory: SessionListGatewayFactory,
|
||||
private val watermarkStore: UnreadWatermarkStore = InMemoryUnreadWatermarkStore(),
|
||||
private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL,
|
||||
) {
|
||||
private val _uiState = MutableStateFlow(SessionListUiState())
|
||||
|
||||
/** The single snapshot the session-list / dashboard renders from. */
|
||||
public val uiState: StateFlow<SessionListUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** Serialises network reads so a poll tick and a manual refresh never race on [uiState]. */
|
||||
private val fetchMutex = Mutex()
|
||||
|
||||
/** The paired hosts (with their [Host.endpoint]), by id — the UI only sees the id/name [HostOption]s. */
|
||||
private var hostsById: Map<String, Host> = emptyMap()
|
||||
|
||||
/** The active host id; sticky across polls, defaults to the first host until the user switches. */
|
||||
private var activeHostId: String? = null
|
||||
|
||||
/** Local unread watermarks (persisted through [watermarkStore]); loaded once, lazily. */
|
||||
private var ledger: UnreadLedger = UnreadLedger()
|
||||
private var ledgerLoaded = false
|
||||
|
||||
/**
|
||||
* STARTED-scoped poll loop (plan §6.6): fetch the active host's list, wait [pollInterval], repeat.
|
||||
* Cancellation (app backgrounded) stops it cleanly. Run inside `repeatOnLifecycle(STARTED)`.
|
||||
*/
|
||||
public suspend fun poll() {
|
||||
while (true) {
|
||||
fetch(markRefreshing = false)
|
||||
delay(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull-to-refresh: one immediate fetch with the refresh spinner shown. */
|
||||
public suspend fun refresh() {
|
||||
fetch(markRefreshing = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active host (multi-host menu) and fetch it immediately. An unknown id is ignored (the
|
||||
* host may have been removed between render and tap).
|
||||
*/
|
||||
public suspend fun selectHost(hostId: String) {
|
||||
if (!hostsById.containsKey(hostId)) return
|
||||
activeHostId = hostId
|
||||
fetch(markRefreshing = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the unread dot for [sessionId] when the user opens it: record the row's current
|
||||
* `lastOutputAt` as the seen-watermark (monotonic), persist it, and re-derive the rows so the dot
|
||||
* drops immediately. A no-op when the row is unknown or has no output timestamp (nothing to clear).
|
||||
*/
|
||||
public suspend fun markSeen(sessionId: UUID) {
|
||||
val row = _uiState.value.rows.firstOrNull { it.id == sessionId } ?: return
|
||||
val at = row.info.lastOutputAt ?: return
|
||||
ledger = ledger.record(sessionId.toString(), at)
|
||||
runCatching { watermarkStore.save(ledger) }
|
||||
_uiState.update { it.copy(rows = it.rows.map { r -> r.withUnread(ledger) }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic swipe-to-kill: remove the row from [uiState] at once, then `DELETE /live-sessions/:id`.
|
||||
* 204 and 404 (already gone) are BOTH success — the row stays removed. Any other failure restores the
|
||||
* row (the session is still alive) and the next poll reconciles authoritatively.
|
||||
*/
|
||||
public suspend fun killSession(sessionId: UUID) {
|
||||
val index = _uiState.value.rows.indexOfFirst { it.id == sessionId }
|
||||
if (index < 0) return
|
||||
val removed = _uiState.value.rows[index]
|
||||
_uiState.update { it.copy(rows = it.rows.filterNot { r -> r.id == sessionId }) }
|
||||
|
||||
val gateway = activeGateway() ?: return
|
||||
try {
|
||||
gateway.killSession(sessionId)
|
||||
} catch (_: ApiClientError.SessionNotFound) {
|
||||
// 404 = already gone = success (plan §4.3). Keep it removed.
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Throwable) {
|
||||
restoreRow(index, removed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun fetch(markRefreshing: Boolean) {
|
||||
fetchMutex.withLock {
|
||||
ensureLedgerLoaded()
|
||||
if (markRefreshing) _uiState.update { it.copy(isRefreshing = true) }
|
||||
|
||||
val hosts = runCatching { hostStore.loadAll() }.getOrDefault(emptyList())
|
||||
hostsById = hosts.associateBy { it.id }
|
||||
val active = resolveActiveHost(hosts)
|
||||
val hostOptions = hosts.map {
|
||||
HostOption(id = it.id, name = it.name, isActive = it.id == active?.id, hasDeviceCert = it.hasDeviceCert)
|
||||
}
|
||||
|
||||
if (active == null) {
|
||||
// No paired host (or none matches) → an empty chooser; the screen offers pairing.
|
||||
_uiState.value = SessionListUiState(hosts = hostOptions, activeHostId = null)
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val result = runCatching { gatewayFactory.create(active).liveSessions() }
|
||||
result.fold(
|
||||
onSuccess = { list ->
|
||||
_uiState.value = SessionListUiState(
|
||||
hosts = hostOptions,
|
||||
activeHostId = active.id,
|
||||
rows = sessionRowsOf(list, ledger),
|
||||
isRefreshing = false,
|
||||
hasLoadError = false,
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (error is CancellationException) throw error
|
||||
// Keep the last-good rows on a transient poll failure — only flag the error banner.
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
hosts = hostOptions,
|
||||
activeHostId = active.id,
|
||||
isRefreshing = false,
|
||||
hasLoadError = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the current active host if it still exists; otherwise fall back to the first paired host. */
|
||||
private fun resolveActiveHost(hosts: List<Host>): Host? {
|
||||
val current = activeHostId?.let { id -> hosts.firstOrNull { it.id == id } }
|
||||
val resolved = current ?: hosts.firstOrNull()
|
||||
activeHostId = resolved?.id
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun activeGateway(): SessionListGateway? =
|
||||
activeHostId?.let { hostsById[it] }?.let(gatewayFactory::create)
|
||||
|
||||
private fun restoreRow(index: Int, row: SessionRow) {
|
||||
_uiState.update { state ->
|
||||
if (state.rows.any { it.id == row.id }) return@update state // a poll already re-added it.
|
||||
val at = index.coerceIn(0, state.rows.size)
|
||||
state.copy(rows = state.rows.toMutableList().apply { add(at, row) })
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureLedgerLoaded() {
|
||||
if (ledgerLoaded) return
|
||||
ledger = runCatching { watermarkStore.load() }.getOrDefault(UnreadLedger())
|
||||
ledgerLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the tolerant-decoded `GET /live-sessions` list to display rows: sanitize the OSC title, resolve
|
||||
* the status badge, and light the unread dot from the [ledger]. Pure + deterministic — the JVM-tested
|
||||
* core of the poll (order-preserving, one row per entry, never throws on a partial/edge-case list).
|
||||
*/
|
||||
public fun sessionRowsOf(sessions: List<LiveSessionInfo>, ledger: UnreadLedger): List<SessionRow> =
|
||||
sessions.map { info ->
|
||||
SessionRow(
|
||||
info = info,
|
||||
displayStatus = if (info.exited) DisplayStatus.Exited else DisplayStatus.from(info.status),
|
||||
title = TitleSanitizer.sanitize(info.title.orEmpty()),
|
||||
isUnread = ledger.isUnread(info.id.toString(), info.lastOutputAt),
|
||||
)
|
||||
}
|
||||
|
||||
/** The session-list / dashboard snapshot. Empty [rows] + null [activeHostId] = the empty (pair-a-host) state. */
|
||||
public data class SessionListUiState(
|
||||
/** Paired hosts for the multi-host switch (checkmark on [HostOption.isActive]). */
|
||||
val hosts: List<HostOption> = emptyList(),
|
||||
/** The host currently listed, or null when no host is paired. */
|
||||
val activeHostId: String? = null,
|
||||
/** One row per running/just-exited session on the active host, in server order. */
|
||||
val rows: List<SessionRow> = emptyList(),
|
||||
/** Pull-to-refresh spinner state. */
|
||||
val isRefreshing: Boolean = false,
|
||||
/** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */
|
||||
val hasLoadError: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One session row. [info] is the raw tolerant-decoded record (the thumbnail key `(id, lastOutputAt)`,
|
||||
* telemetry, cwd, clientCount, geometry pass straight through to the row Composable); the derived fields
|
||||
* are the sanitized/resolved display values the row renders and the tests assert.
|
||||
*/
|
||||
public data class SessionRow(
|
||||
val info: LiveSessionInfo,
|
||||
val displayStatus: DisplayStatus,
|
||||
/** Sanitized OSC title ([TitleSanitizer]); "" = no title (the row shows the id/cwd instead). */
|
||||
val title: String,
|
||||
val isUnread: Boolean,
|
||||
) {
|
||||
/** The session id (thumbnail key + open/kill target). */
|
||||
val id: UUID get() = info.id
|
||||
|
||||
/** "161×50" — tabular geometry label for the row (× is U+00D7, not the letter x). */
|
||||
val dimensions: String get() = "${info.cols}×${info.rows}"
|
||||
|
||||
/** Re-derive [isUnread] against a new [ledger] (used after [SessionListViewModel.markSeen]). */
|
||||
internal fun withUnread(ledger: UnreadLedger): SessionRow =
|
||||
copy(isUnread = ledger.isUnread(id.toString(), info.lastOutputAt))
|
||||
}
|
||||
|
||||
/** One host in the multi-host switch menu (id/name only — the endpoint stays inside the VM). */
|
||||
public data class HostOption(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val isActive: Boolean,
|
||||
val hasDeviceCert: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-host list gateway — abstracts [ApiClient] so the VM is JVM-testable against a fake. Production is
|
||||
* [ApiClientSessionGateway] over the shared mTLS `HttpTransport`; tests queue canned lists.
|
||||
*/
|
||||
public interface SessionListGateway {
|
||||
/** `GET /live-sessions` — tolerant-decoded list for the active host. */
|
||||
public suspend fun liveSessions(): List<LiveSessionInfo>
|
||||
|
||||
/** `DELETE /live-sessions/:id` — 204 success; 404 (already gone) surfaces as [ApiClientError.SessionNotFound]. */
|
||||
public suspend fun killSession(id: UUID)
|
||||
}
|
||||
|
||||
/** Mints a [SessionListGateway] for the active [Host] (wiring binds it to [ApiClient] per host endpoint). */
|
||||
public fun interface SessionListGatewayFactory {
|
||||
public fun create(host: Host): SessionListGateway
|
||||
}
|
||||
|
||||
/** Production [SessionListGateway] delegating to a per-host [ApiClient]. */
|
||||
public class ApiClientSessionGateway(private val api: ApiClient) : SessionListGateway {
|
||||
override suspend fun liveSessions(): List<LiveSessionInfo> = api.liveSessions()
|
||||
override suspend fun killSession(id: UUID): Unit = api.killSession(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence seam for the unread watermarks (plan §2 — DataStore-backed in production, wired outside
|
||||
* A20's lane). [InMemoryUnreadWatermarkStore] is the default so the VM works before that store exists.
|
||||
*/
|
||||
public interface UnreadWatermarkStore {
|
||||
public suspend fun load(): UnreadLedger
|
||||
public suspend fun save(ledger: UnreadLedger)
|
||||
}
|
||||
|
||||
/** In-memory [UnreadWatermarkStore] (default). Watermarks live for the process only until DataStore lands. */
|
||||
public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()) : UnreadWatermarkStore {
|
||||
@Volatile private var current: UnreadLedger = initial
|
||||
override suspend fun load(): UnreadLedger = current
|
||||
override suspend fun save(ledger: UnreadLedger) {
|
||||
current = ledger
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import wang.yaojia.webterm.designsystem.DesignSpec
|
||||
import wang.yaojia.webterm.wire.TimelineEvent
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* # TimelineViewModel (A28) — state for the full activity-timeline drill-down sheet.
|
||||
*
|
||||
* Mirrors the iOS `TimelineViewModel` and the web timeline panel (`public/timeline.ts`): fetches
|
||||
* `GET /live-sessions/:id/events` (plan §4.2), then presents rows **newest-first, capped at
|
||||
* [MAX_EVENTS]** — line-for-line the web `render()` pipeline (`slice(0, maxEvents)` THEN reverse).
|
||||
*
|
||||
* One VM per presentation (the away-digest「展开」builds a fresh one), so every open re-fetches. The
|
||||
* fetch closure is injected — production wraps `ApiClient.events` via [forSession]; the JVM test
|
||||
* injects fakes. A plain presenter (NOT `androidx.lifecycle.ViewModel`, like [DiffViewModel]/
|
||||
* [PairingViewModel]) so it runs under `runTest` with no `Dispatchers.Main`.
|
||||
*
|
||||
* ### Untrusted boundary + lossy decode (plan §4.2 / §8)
|
||||
* `ApiClient.events` already dropped malformed JSON entries and mapped a non-array body to `[]`, but
|
||||
* it deliberately lets **unknown-class** entries survive shape-decode ("filtered downstream"). This VM
|
||||
* is that downstream: [presentation] drops any event whose class the server doesn't currently emit
|
||||
* ([TimelineEvent.hasKnownClass]) and keeps the rest — the plan §3.1 "consumers drop unknowns" rule.
|
||||
* The class→glyph/color mapping ([TimelineEventStyle]) stays **total** anyway (defense in depth): an
|
||||
* unknown class degrades to a neutral glyph/color rather than trapping. Labels are server text →
|
||||
* rendered INERT by the sheet (no linkify/markdown, §8).
|
||||
*
|
||||
* ### Empty is NEVER an error
|
||||
* The server replies `[]` both for "no activity yet" AND for timeline capture disabled host-side
|
||||
* (`TIMELINE_ENABLED=0`); a thrown fetch is [TimelinePhase.Failed] (explicit, retryable via [load]).
|
||||
*/
|
||||
public class TimelineViewModel(
|
||||
private val fetch: suspend () -> List<TimelineEvent>,
|
||||
private val zone: ZoneId = ZoneId.systemDefault(),
|
||||
) {
|
||||
private val _phase = MutableStateFlow<TimelinePhase>(TimelinePhase.Loading)
|
||||
|
||||
/** The single snapshot [TimelineSheet] renders from. */
|
||||
public val phase: StateFlow<TimelinePhase> = _phase.asStateFlow()
|
||||
|
||||
/**
|
||||
* Fetch and present. Also the「重试」path — callable again from [TimelinePhase.Failed]. A thrown
|
||||
* fetch becomes [TimelinePhase.Failed]; cancellation (a superseding load) propagates so a stale
|
||||
* fetch can't overwrite fresh state.
|
||||
*/
|
||||
public suspend fun load() {
|
||||
_phase.value = TimelinePhase.Loading
|
||||
val outcome = try {
|
||||
Result.success(fetch())
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
_phase.value = outcome.fold(
|
||||
onSuccess = { presentation(it, zone) },
|
||||
onFailure = { TimelinePhase.Failed },
|
||||
)
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/** Web parity: `DEFAULT_MAX_EVENTS` (`public/timeline.ts:20`). */
|
||||
public const val MAX_EVENTS: Int = 50
|
||||
|
||||
/**
|
||||
* Assembly seam for the digest「展开」entry point (plan §5 A28 "wired to away-digest expand").
|
||||
* No adopted `sessionId` yet → no sheet (defensive — a digest only arrives after `attached`, so
|
||||
* the id is normally known). Otherwise [sessionId] is passed to [source] verbatim on every load;
|
||||
* production supplies `ApiClient::events` as [source].
|
||||
*/
|
||||
public fun forSession(
|
||||
sessionId: UUID?,
|
||||
source: suspend (UUID) -> List<TimelineEvent>,
|
||||
): TimelineViewModel? =
|
||||
sessionId?.let { id -> TimelineViewModel(fetch = { source(id) }) }
|
||||
|
||||
/**
|
||||
* Pure presentation reducer, mirroring the web `render()` pipeline (`public/timeline.ts:174-189`)
|
||||
* plus the Android downstream unknown-class filter: drop unknown-class events (keep the rest),
|
||||
* take the first [MAX_EVENTS] (web `slice(0, 50)`), THEN reverse (newest-first). All-empty (or
|
||||
* all-unknown) → [TimelinePhase.Empty], never an error.
|
||||
*/
|
||||
public fun presentation(
|
||||
events: List<TimelineEvent>,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): TimelinePhase {
|
||||
val rows = events.asSequence()
|
||||
.filter { it.hasKnownClass } // lossy: drop unknown/future classes, keep the rest.
|
||||
.take(MAX_EVENTS) // web slice(0, maxEvents) on the filtered list.
|
||||
.toList()
|
||||
.reversed() // newest-first (server returns oldest-first).
|
||||
.mapIndexed { index, event -> TimelineRow.from(index, event, zone) }
|
||||
return if (rows.isEmpty()) TimelinePhase.Empty else TimelinePhase.Loaded(rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rendering phase — explicit so the sheet can never show an error and rows at the same time. */
|
||||
public sealed interface TimelinePhase {
|
||||
/** Before/while the fetch runs. */
|
||||
public data object Loading : TimelinePhase
|
||||
|
||||
/** Server returned `[]` (no activity) OR every event was unknown-class after filtering. */
|
||||
public data object Empty : TimelinePhase
|
||||
|
||||
/** Fetch threw — retryable via [TimelineViewModel.load]. */
|
||||
public data object Failed : TimelinePhase
|
||||
|
||||
/** Display-ready rows: filtered, capped and newest-first. */
|
||||
public data class Loaded(val rows: List<TimelineRow>) : TimelinePhase
|
||||
}
|
||||
|
||||
/**
|
||||
* One display-ready timeline row: "HH:mm · glyph · label". [colorSpec] is a raw ARGB constant (the
|
||||
* A13 timeline/status token value) or `null` for the theme-adaptive secondary-text fallback; the sheet
|
||||
* lifts it into a Compose `Color`. [label] is untrusted server text — rendered INERT (§8).
|
||||
*/
|
||||
public data class TimelineRow(
|
||||
/** Stable list key = position in the displayed list (offset identity; rows are static per load). */
|
||||
val key: Int,
|
||||
val time: String,
|
||||
val glyph: String,
|
||||
val colorSpec: Long?,
|
||||
val label: String,
|
||||
) {
|
||||
public companion object {
|
||||
internal fun from(key: Int, event: TimelineEvent, zone: ZoneId): TimelineRow = TimelineRow(
|
||||
key = key,
|
||||
time = TimelineRowFormat.timeLabel(event.at, zone),
|
||||
glyph = TimelineEventStyle.glyph(event.eventClass),
|
||||
colorSpec = TimelineEventStyle.colorSpec(event.eventClass),
|
||||
label = event.label,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* class → glyph / color mapping. Glyphs mirror the web `timelineIcon` verbatim
|
||||
* (`public/timeline.ts:88-96`); colors are the frozen [DesignSpec] tokens (A13) — the same values as
|
||||
* `WebTermColors.timelineTool`/`timelineUser` and the semantic status colors. Total functions: the
|
||||
* server is untrusted, so an unknown class degrades to a neutral glyph / theme-secondary color instead
|
||||
* of trapping (even though [TimelineViewModel.presentation] already drops unknown classes).
|
||||
*/
|
||||
public object TimelineEventStyle {
|
||||
/** Neutral glyph for an unknown/future class. */
|
||||
public const val FALLBACK_GLYPH: String = "•"
|
||||
|
||||
public fun glyph(eventClass: String): String = when (eventClass) {
|
||||
"tool" -> "🔧"
|
||||
"waiting" -> "⏳"
|
||||
"done" -> "✓"
|
||||
"stuck" -> "⚠"
|
||||
"user" -> "💬"
|
||||
else -> FALLBACK_GLYPH
|
||||
}
|
||||
|
||||
/**
|
||||
* The ARGB token constant for a class, or `null` to signal "use the theme-adaptive secondary text
|
||||
* color" (the unknown fallback — the only theme-dependent case). tool/user use the timeline-specific
|
||||
* tokens; waiting/stuck/done reuse the semantic status tokens (same convention as the session list).
|
||||
*/
|
||||
public fun colorSpec(eventClass: String): Long? = when (eventClass) {
|
||||
"tool" -> DesignSpec.TIMELINE_TOOL
|
||||
"waiting" -> DesignSpec.STATUS_WAITING
|
||||
"done" -> DesignSpec.STATUS_WORKING
|
||||
"stuck" -> DesignSpec.STATUS_STUCK
|
||||
"user" -> DesignSpec.TIMELINE_USER
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row time formatting — mirrors web `formatHHMM` (`public/timeline.ts:101-106`): 24-hour wall-clock
|
||||
* "HH:mm". [Locale.ROOT] keeps the digits fixed regardless of the user's locale; [zone] is injectable
|
||||
* for deterministic tests.
|
||||
*/
|
||||
public object TimelineRowFormat {
|
||||
private val FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)
|
||||
|
||||
public fun timeLabel(atMs: Long, zone: ZoneId = ZoneId.systemDefault()): String =
|
||||
Instant.ofEpochMilli(atMs).atZone(zone).format(FORMATTER)
|
||||
}
|
||||
|
||||
/** User-visible copy (named constants — plan engineering standard; empty ≠ error). */
|
||||
public object TimelineCopy {
|
||||
public const val TITLE: String = "活动时间线"
|
||||
public const val EMPTY_TITLE: String = "暂无活动"
|
||||
public const val EMPTY_DETAIL: String =
|
||||
"会话还没有可展示的事件;主机关闭时间线(TIMELINE_ENABLED=0)时也会显示为空。"
|
||||
public const val LOAD_FAILED: String = "时间线加载失败"
|
||||
public const val LOAD_FAILED_DETAIL: String = "无法从主机获取活动时间线,请检查连接后重试。"
|
||||
public const val RETRY: String = "重试"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import dagger.Lazy
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Builds an [ApiClient] bound to a specific [HostEndpoint] over the ONE shared [HttpTransport] (A15
|
||||
* wiring). [ApiClient] is per-host (it stamps `Origin` from its endpoint), so it cannot be a plain
|
||||
* singleton — this factory injects the shared transport once and mints a client per host on demand
|
||||
* (session list, projects, timeline, cert-gated probe, FCM registration).
|
||||
*
|
||||
* ### The transport is [Lazy] (FIX 3 — no AndroidKeyStore/Tink I/O on the injection/Main thread)
|
||||
* Resolving [HttpTransport] builds the shared `OkHttpClient` (which first-touches the mTLS material:
|
||||
* slow AndroidKeyStore + Tink I/O). A `dagger.Lazy<HttpTransport>` keeps constructing this factory (and
|
||||
* `AppEnvironment`) cheap; the heavy build happens off `Main` in [warm] (called from
|
||||
* `AppEnvironment.warmUp()` on `Dispatchers.IO`). `HttpTransport` and `TermTransport` share the SAME
|
||||
* client singleton, so warming either builds it once for both WS and REST.
|
||||
*/
|
||||
@Singleton
|
||||
public class ApiClientFactory @Inject constructor(
|
||||
private val http: Lazy<HttpTransport>,
|
||||
) {
|
||||
/**
|
||||
* Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs
|
||||
* on the CALLER's thread. Call ONLY from a background dispatcher (`AppEnvironment.warmUp()`).
|
||||
*/
|
||||
public fun warm() {
|
||||
http.get()
|
||||
}
|
||||
|
||||
/** Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. */
|
||||
public fun create(endpoint: HostEndpoint): ApiClient = ApiClient(endpoint = endpoint, http = http.get())
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.api.pairing.runPairingProbe
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The composition root (plan §3 `:app` wiring): the single typed handle to the frozen object graph
|
||||
* that AW4 screens/ViewModels resolve their collaborators through. Hilt assembles the graph via the
|
||||
* per-feature `di/` Hilt module boundaries; this aggregate is what the app-level nav / ViewModels inject
|
||||
* to reach the app-scoped singletons without naming every provider.
|
||||
*
|
||||
* ### Confinement contract (invariant #4 — recorded here at the seam)
|
||||
* The object graph enforces exactly two cross-boundary paths, and NOTHING here widens them:
|
||||
* - **UI→engine:** through [TerminalSessionController] (`sendInput`/`resize`/`notifyForegrounded`/
|
||||
* `decideGate`), whose engine runs on its own `limitedParallelism(1)` confinement.
|
||||
* - **engine→UI:** through the PER-SESSION [EventBus]'s per-consumer channels (R10). The bus is minted
|
||||
* inside [RetainedSessionHolder.bind]; it is NOT an app singleton (FIX 1) so sessions never cross-talk.
|
||||
* Engine state is never touched off-confinement and emulator/scrollback state never off-`Main`.
|
||||
*
|
||||
* ### Off-`Main` warm-up (FIX 3)
|
||||
* The mTLS transports and [identityRepository] are behind `dagger.Lazy` so constructing this root does
|
||||
* NO AndroidKeyStore/Tink/`OkHttpClient` I/O on the injection (often `Main`) thread. [warmUp] resolves
|
||||
* them on `Dispatchers.IO`, so the shared client is built and the device identity first-touched off the
|
||||
* UI thread. A21/A27 call [warmUp] before the first `bind()` / cert-screen open.
|
||||
*
|
||||
* What lives here (all app singletons):
|
||||
* - [hostStore] / [lastSessionStore] — persisted, non-secret UI state (DataStore).
|
||||
* - [identityRepository] — the mTLS device identity bridged onto the ONE shared `OkHttpClient` (lazy).
|
||||
* - [apiClientFactory] / [sessionEngineFactory] — per-host / per-session builders over the shared
|
||||
* transports (lazy).
|
||||
* - [coldStartPolicy] — the cold-launch route seam (A29).
|
||||
* Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its
|
||||
* [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder].
|
||||
*/
|
||||
@Singleton
|
||||
public class AppEnvironment @Inject constructor(
|
||||
public val hostStore: HostStore,
|
||||
public val lastSessionStore: LastSessionStore,
|
||||
public val apiClientFactory: ApiClientFactory,
|
||||
public val sessionEngineFactory: SessionEngineFactory,
|
||||
public val coldStartPolicy: ColdStartPolicy,
|
||||
private val identityRepositoryLazy: Lazy<IdentityRepository>,
|
||||
private val httpTransportLazy: Lazy<HttpTransport>,
|
||||
private val termTransportLazy: Lazy<TermTransport>,
|
||||
) {
|
||||
/**
|
||||
* The mTLS device identity (A27 cert screen). Resolving the [Lazy] merely constructs the repository
|
||||
* (I/O-free per A11); its FIRST touch (`currentSummary`/`hasInstalledIdentity`/handshake) does the
|
||||
* slow AndroidKeyStore + Tink I/O and MUST run off `Main` — [warmUp] does that first touch, and A27
|
||||
* likewise touches it off the UI thread.
|
||||
*/
|
||||
public val identityRepository: IdentityRepository get() = identityRepositoryLazy.get()
|
||||
|
||||
/**
|
||||
* The shared REST [HttpTransport] (A24 diff-fetcher seam). Resolving the [Lazy] builds the shared
|
||||
* `OkHttpClient` (mTLS I/O) — call ONLY after [warmUp] has run off `Main`, else this first-touch
|
||||
* does AndroidKeyStore/Tink work on the calling thread.
|
||||
*/
|
||||
public val httpTransport: HttpTransport get() = httpTransportLazy.get()
|
||||
|
||||
/**
|
||||
* Build the two-step pairing prober (A19) over the ONE shared transports. The transports are
|
||||
* resolved LAZILY inside `probe()` (not at build time) so constructing the prober on the UI thread
|
||||
* never triggers the mTLS/OkHttp build — the actual resolve happens in the probe's own coroutine,
|
||||
* after [warmUp].
|
||||
*/
|
||||
public fun buildPairingProber(): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) }
|
||||
|
||||
/**
|
||||
* Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving
|
||||
* either transport builds the one shared client (which reads the mTLS material); touching the
|
||||
* identity forces its first AndroidKeyStore/Tink read. Idempotent at the singleton level — the
|
||||
* client/identity are built once and cached. Safe to call from any coroutine; hops to
|
||||
* `Dispatchers.IO` internally.
|
||||
*/
|
||||
public suspend fun warmUp() {
|
||||
withContext(Dispatchers.IO) {
|
||||
sessionEngineFactory.warm() // builds TermTransport → the shared OkHttpClient → reads SSL material
|
||||
apiClientFactory.warm() // HttpTransport reuses the SAME already-built client
|
||||
runCatching { identityRepositoryLazy.get().hasInstalledIdentity() } // first AndroidKeyStore/Tink touch
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Where the app should land on a cold launch (plan §1 P1 continue-last-session; A29). */
|
||||
public enum class ColdStartRoute {
|
||||
/** No paired host yet → the pairing flow (A19). */
|
||||
PAIRING,
|
||||
|
||||
/** At least one paired host → the session chooser / dashboard (A20). */
|
||||
SESSIONS,
|
||||
}
|
||||
|
||||
/**
|
||||
* The cold-start routing seam A29 consumes (plan §5 A15/A29): decide the FIRST destination from
|
||||
* persisted state alone — no host → pairing, else sessions. Kept behind an interface so A29's nav
|
||||
* wiring and its tests depend on the decision, not on the store.
|
||||
*/
|
||||
public interface ColdStartPolicy {
|
||||
/** The initial route, derived from whether any host is paired. Suspends for the [HostStore] read. */
|
||||
public suspend fun initialRoute(): ColdStartRoute
|
||||
}
|
||||
|
||||
/**
|
||||
* Default [ColdStartPolicy]: route on host presence via [HostStore]. The "continue-last-session"
|
||||
* re-entry banner (A29) is layered ON TOP of a [ColdStartRoute.SESSIONS] landing from
|
||||
* `LastSessionStore` — this policy only chooses pairing-vs-sessions, keeping the decision minimal.
|
||||
*/
|
||||
public class DefaultColdStartPolicy @Inject constructor(
|
||||
private val hostStore: HostStore,
|
||||
) : ColdStartPolicy {
|
||||
override suspend fun initialRoute(): ColdStartRoute =
|
||||
if (hostStore.loadAll().isEmpty()) ColdStartRoute.PAIRING else ColdStartRoute.SESSIONS
|
||||
}
|
||||
147
android/app/src/main/java/wang/yaojia/webterm/wiring/EventBus.kt
Normal file
147
android/app/src/main/java/wang/yaojia/webterm/wiring/EventBus.kt
Normal file
@@ -0,0 +1,147 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.session.Output
|
||||
import wang.yaojia.webterm.session.SessionEvent
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* The engine→UI event fan-out (A15 wiring freeze; plan §0 invariant #4, §9 R10). The Android analogue
|
||||
* of the iOS **per-subscriber `AsyncStream`**: the [SessionEngine][wang.yaojia.webterm.session.SessionEngine]
|
||||
* publishes a SINGLE `SessionEvent` stream (its `events` is a `receiveAsFlow` — one consumer only),
|
||||
* and this bus fans it out to the two UI consumers so the terminal and the cockpit each observe it
|
||||
* independently and losslessly.
|
||||
*
|
||||
* ### PER-SESSION, never a `@Singleton` (FIX 1 — cross-talk fix)
|
||||
* A [SessionEvent] carries NO session id, so an app-wide singleton bus shared by every
|
||||
* `RetainedSessionHolder` would let one session's `Output`/`Gate` reach another session's UI. This bus
|
||||
* is therefore constructed INSIDE the session unit (one bus per engine, alongside the engine's confined
|
||||
* scope in `RetainedSessionHolder.bind()`) and dies with the session. There is NO Hilt binding for it.
|
||||
*
|
||||
* ### Two TYPED sub-streams, not one generic subscribe (FIX 4 — no cross-buffering)
|
||||
* The bus exposes exactly two consumer streams:
|
||||
* - [outputBytes] — `Output(String)` frames only, UTF-8-decoded to `ByteArray` on [decodeDispatcher]
|
||||
* (`Dispatchers.Default`) so the multi-MB ring-buffer replay decode never runs on `Main` (§6.6).
|
||||
* Consumed ONLY by the terminal emulator.
|
||||
* - [controlEvents] — every NON-`Output` event (connection/gate/telemetry/digest/exit). Consumed
|
||||
* ONLY by the cockpit (banner / gate / telemetry chips / away digest).
|
||||
* Because [publish] offers each event only to the consumers that WANT it, the terminal never buffers a
|
||||
* gate it ignores and the cockpit never buffers the multi-MB `Output` replay it ignores.
|
||||
*
|
||||
* ### Per-consumer `Channel`s and NOT a single `SharedFlow` (R10 — the load-bearing decision)
|
||||
* Each consumer gets its OWN unbounded [Channel] (`Channel.UNLIMITED`). Fan-out is a non-suspending
|
||||
* [trySend] to each channel:
|
||||
* - **never stalls another consumer** — `trySend` on an unbounded channel never suspends, so one slow
|
||||
* collector cannot back-pressure the pump or the fast collector;
|
||||
* - **never drops** — an unbounded buffer holds a slow consumer's backlog (including a `Gate`) until
|
||||
* it drains, so nothing is lost.
|
||||
* A single `SharedFlow` cannot provide this: `onBufferOverflow=SUSPEND` head-of-line-blocks OUTPUT for
|
||||
* every consumer; `DROP_*` could silently lose a `Gate` (a correctness bug).
|
||||
*
|
||||
* ### Eager registration (FIX 2a — no pre-subscription drop)
|
||||
* [outputBytes]/[controlEvents] register their per-consumer mailbox EAGERLY at call time (not deferred
|
||||
* to first collection). The `RetainedSessionHolder` constructs the controller — which calls both — and
|
||||
* returns it UN-STARTED; only later does the terminal screen call `controller.start()` (FIX 2b), which
|
||||
* starts the engine→bus pump AND `engine.start()`. Since both mailboxes exist before ANYTHING is
|
||||
* published, the `attached` frame and the multi-MB replay land in an already-registered mailbox and are
|
||||
* never dropped.
|
||||
*
|
||||
* ### Mailbox lives for the SESSION, not the collector (FIX 1 — config-change re-collection)
|
||||
* A mailbox is tied to the BUS (session) lifetime, NOT to any single collection. There are exactly TWO
|
||||
* fixed per-session consumers (output + control) registered once at controller construction — there is
|
||||
* no dynamic subscribe/unsubscribe — so the returned flows are RE-COLLECTABLE: when a config change
|
||||
* (rotation/fold) cancels the terminal screen's collector, the mailbox is NOT closed; the recomposed
|
||||
* screen re-collects the SAME flow and resumes draining the events buffered during the gap AND every new
|
||||
* one, with ZERO loss. (Had the flow closed its mailbox on cancellation, the re-collect would iterate a
|
||||
* dead channel and the terminal would be blank forever after one rotation.) The mailboxes are released
|
||||
* exactly when the session ends, by [close] (called from `RetainedSessionHolder`'s teardown after the
|
||||
* engine close-join). An uncollected stream simply keeps its mailbox buffering for the session lifetime.
|
||||
*
|
||||
* ### Purity (invariant #4 / testability)
|
||||
* PURE Kotlin — coroutines only, no Android import — so the fan-out property is unit-testable under
|
||||
* `runTest` ([decodeDispatcher] is injectable so the decode runs on virtual time in tests). The engine's
|
||||
* confinement (`limitedParallelism(1)`) and the UI's `Main.immediate` never touch each other's state;
|
||||
* this bus is the only engine→UI seam, and [publish] is a plain non-suspending hand-off, safe to call
|
||||
* from the engine's confined dispatcher.
|
||||
*/
|
||||
public class EventBus(
|
||||
private val decodeDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) {
|
||||
/** One consumer mailbox plus the predicate selecting the events it wants (FIX 4 typing). */
|
||||
private class Consumer(
|
||||
val mailbox: Channel<SessionEvent>,
|
||||
val wants: (SessionEvent) -> Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Live consumer mailboxes. Copy-on-write so [publish] (called from the engine's confined
|
||||
* dispatcher) iterates a stable snapshot while registration/cancellation add/remove concurrently.
|
||||
*/
|
||||
private val consumers = CopyOnWriteArrayList<Consumer>()
|
||||
|
||||
/**
|
||||
* Register a [wants]-filtered mailbox EAGERLY (FIX 2a) and return a RE-COLLECTABLE stream that
|
||||
* drains it (FIX 1). The mailbox is added to [consumers] the instant this is called and lives for
|
||||
* the BUS (session) lifetime — cancelling a collection does NOT close or unregister it, so a
|
||||
* config-change re-collect resumes on the SAME mailbox and loses nothing. `receiveAsFlow` (not
|
||||
* `consumeAsFlow`) is what makes the flow collectable more than once. Mailboxes are released only by
|
||||
* [close] when the session ends.
|
||||
*/
|
||||
private fun register(wants: (SessionEvent) -> Boolean): Flow<SessionEvent> {
|
||||
val consumer = Consumer(Channel(Channel.UNLIMITED), wants)
|
||||
consumers.add(consumer) // EAGER: mailbox exists before start() → nothing published can be dropped
|
||||
return consumer.mailbox.receiveAsFlow()
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal's stream: `Output` frames only, UTF-8-decoded to bytes on [decodeDispatcher] so the
|
||||
* multi-MB replay decode is off `Main` (§6.6). Fed verbatim to the emulator (`:terminal-view`).
|
||||
*/
|
||||
public fun outputBytes(): Flow<ByteArray> =
|
||||
register { it is Output }
|
||||
.map { (it as Output).data.toByteArray(Charsets.UTF_8) }
|
||||
.flowOn(decodeDispatcher)
|
||||
|
||||
/** The cockpit's stream: every NON-`Output` event (connection/gate/telemetry/digest/exit). */
|
||||
public fun controlEvents(): Flow<SessionEvent> = register { it !is Output }
|
||||
|
||||
/**
|
||||
* Fan [event] out to every registered consumer that WANTS it (FIX 4). Non-suspending and lossless:
|
||||
* [trySend] on an unbounded channel never suspends (no head-of-line stall) and never fails while the
|
||||
* channel is open (no drop). A `trySend` to an already-closing mailbox (racing cancellation) fails
|
||||
* harmlessly and is ignored — that consumer is going away anyway.
|
||||
*/
|
||||
public fun publish(event: SessionEvent) {
|
||||
consumers.forEach { if (it.wants(event)) it.mailbox.trySend(event) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the engine→UI pump: collect the engine's single-consumer [source] stream and [publish]
|
||||
* each event to the fan-out. Runs until [source] completes (the engine closed its channel) or
|
||||
* [scope] is cancelled. Returns the pump [Job] so the caller can tie it to the session lifetime.
|
||||
* Called from `controller.start()` AFTER the mailboxes are registered, so no event is missed.
|
||||
*/
|
||||
public fun connect(scope: CoroutineScope, source: Flow<SessionEvent>): Job =
|
||||
scope.launch { source.collect { publish(it) } }
|
||||
|
||||
/**
|
||||
* End-of-session release (FIX 1): close every per-consumer mailbox and clear the registry. Closing a
|
||||
* mailbox ends its `receiveAsFlow` iteration normally (any live collector completes), and a later
|
||||
* [publish] to a closed mailbox `trySend`s harmlessly. Called from `RetainedSessionHolder`'s teardown
|
||||
* AFTER the engine close-join, so the mailboxes are freed exactly when the session dies — never on a
|
||||
* mere config-change collector cancellation (which must leave the mailbox re-collectable).
|
||||
*/
|
||||
public fun close() {
|
||||
consumers.forEach { it.mailbox.close() }
|
||||
consumers.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.components.BannerModel
|
||||
import wang.yaojia.webterm.components.bannerModel
|
||||
import wang.yaojia.webterm.session.SessionEngine
|
||||
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
|
||||
import wang.yaojia.webterm.viewmodels.GateViewModel
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The config-surviving home for a session's ENTIRE per-session unit — the [SessionEngine], its
|
||||
* per-session [EventBus], the [RemoteTerminalSession] emulator (via [TerminalSessionControllerImpl]),
|
||||
* and the retained UI presenters ([GateViewModel] + [bannerState] + [title]) — plan §6.6, the
|
||||
* load-bearing lifecycle contract for A16/A21/A22. A `@HiltViewModel` retained across Activity
|
||||
* recreation, so a **rotation / fold / multi-window / dark-mode** change disposes the Compose tree but
|
||||
* NOT this unit: the surviving emulator is re-bound to the recreated `AndroidView` with no detach, no
|
||||
* ~2 MB replay round-trip, and no scroll-position loss.
|
||||
*
|
||||
* ### Config change vs real background (the distinction that drives everything)
|
||||
* - **Config change** — the Activity is recreated but the ViewModel survives (Android guarantees this).
|
||||
* [onStop] with `isChangingConfigurations == true` does NOTHING: the engine + emulator keep running,
|
||||
* [generation] does NOT bump, and the recreated Compose tree calls [bind] again to reuse the survivor
|
||||
* (re-binding the same emulator to the new `AndroidView`).
|
||||
* - **Real background** — `ON_STOP` that is NOT a config change. [onStop] with
|
||||
* `isChangingConfigurations == false` detaches cleanly (server PTY survives) and bumps [generation]
|
||||
* so the NEXT foreground return rebuilds a fresh emulator that replays the ring and re-fires resize.
|
||||
*
|
||||
* ### The emulator lives HERE, not in composition (FIX 3 — §6.6 config-survival)
|
||||
* [TerminalSessionControllerImpl] (which owns the [RemoteTerminalSession] emulator + scrollback) is
|
||||
* constructed and OWNED here, in the config-surviving `viewModelScope`, NOT `remember`ed in the
|
||||
* composition — a composition-remembered emulator dies on every rotation. `TerminalScreen` obtains the
|
||||
* controller FROM [bind] and re-binds the surviving emulator; the output→emulator collector runs in the
|
||||
* engine's confined [engineScope], so it is cancelled with the SESSION, not the composition (§6.6).
|
||||
*
|
||||
* ### [generation] is Compose-observable (FIX 2)
|
||||
* Backed by [mutableIntStateOf], so a real-background rebuild (a bump) RECOMPOSES `TerminalScreen` and
|
||||
* re-keys its `AndroidView` (fresh emulator + ring replay); a config change (no bump) leaves it unchanged
|
||||
* so the recreated `AndroidView` re-binds the SAME surviving emulator.
|
||||
*
|
||||
* ### Retained control-event consumers (FIX 1/FIX 4 — no split, config-survival)
|
||||
* The banner AND the [GateViewModel] each need the FULL control-event stream. Each is created here and
|
||||
* subscribes to its OWN [TerminalSessionController.controlEvents] mailbox — so they never split events —
|
||||
* and folds in [engineScope], so their state survives a rotation (a composition-scoped fold would reset
|
||||
* to `Hidden`/empty and, worse, leak a fresh orphaned mailbox on every rotation). All consumers register
|
||||
* their mailbox EAGERLY inside [bind], which returns the controller UN-STARTED — so `controller.start()`
|
||||
* (called by the screen) can never drop the `attached` frame or the multi-MB replay.
|
||||
*
|
||||
* ### close() BEFORE scope-cancel — made STRUCTURAL (FIX 5 — do NOT reorder)
|
||||
* [SessionEngine] does **not** close its live WS on scope cancellation, so cancelling the confinement
|
||||
* scope alone would leak the socket to a timeout death — never a clean RFC-6455 `1000` detach. Teardown
|
||||
* therefore calls [SessionEngine.close] FIRST and **awaits the returned [kotlinx.coroutines.Job]**
|
||||
* (`closeJob.join()`) so the `1000` close frame is handed to the transport writer BEFORE the scope is
|
||||
* cancelled, THEN releases the per-session [EventBus] mailboxes and cancels the scope. The
|
||||
* [RemoteTerminalSession] append thread is released too (session end).
|
||||
*
|
||||
* ### Single-session-for-life (no silent mis-route)
|
||||
* A holder binds exactly ONE (endpoint, sessionId) for its lifetime. A re-[bind] with the SAME key is
|
||||
* the config-change reuse path; a re-[bind] with a DIFFERENT key is a nav-scoping programming error and
|
||||
* throws (a new session needs a new nav-scoped holder), so a mis-scoped multi-tab UI can never show the
|
||||
* wrong session behind a silently-ignored argument.
|
||||
*/
|
||||
@HiltViewModel
|
||||
public class RetainedSessionHolder @Inject constructor(
|
||||
private val engineFactory: SessionEngineFactory,
|
||||
) : ViewModel() {
|
||||
|
||||
private var engine: SessionEngine? = null
|
||||
private var engineScope: CoroutineScope? = null
|
||||
private var eventBus: EventBus? = null
|
||||
private var controllerImpl: TerminalSessionControllerImpl? = null
|
||||
private var gateVm: GateViewModel? = null
|
||||
|
||||
/** The (endpoint, sessionId) this holder is bound to for life; a re-[bind] must match it. */
|
||||
private var boundKey: BoundKey? = null
|
||||
|
||||
/** Identity of the single session a holder owns for life; [cwd] is excluded (spawn-only, not identity). */
|
||||
private data class BoundKey(val endpoint: HostEndpoint, val sessionId: String?)
|
||||
|
||||
/**
|
||||
* Bumped ONLY on a genuine background detach (never on a config change), and Compose-observable (FIX
|
||||
* 2): the terminal `AndroidView` is keyed by this so a real background→foreground cycle RECOMPOSES
|
||||
* and rebuilds a fresh emulator (ring replay), while a rotation re-binds the SAME survivor.
|
||||
*/
|
||||
private val generationState = mutableIntStateOf(0)
|
||||
public val generation: Int get() = generationState.intValue
|
||||
|
||||
/** The live controller for the bound session, or null before the first [bind] / after teardown. */
|
||||
public val controller: TerminalSessionController? get() = controllerImpl
|
||||
|
||||
/** The retained gate/telemetry/digest presenter (A22), or null before [bind] / after teardown. */
|
||||
public val gateViewModel: GateViewModel? get() = gateVm
|
||||
|
||||
/** The sanitized OSC 0/2 title (inert), reset per session. Read by the toolbar; survives a rotation. */
|
||||
private val titleState = mutableStateOf("")
|
||||
public val title: State<String> get() = titleState
|
||||
|
||||
/** The retained reconnect-banner model, folded from its OWN control mailbox; survives a rotation. */
|
||||
private val bannerFlow = MutableStateFlow<BannerModel>(BannerModel.Hidden)
|
||||
public val bannerState: StateFlow<BannerModel> = bannerFlow.asStateFlow()
|
||||
|
||||
/**
|
||||
* Where the `output`→`feedRemote` collector runs (default `Main.immediate`, §6.6). Null → resolve
|
||||
* `Dispatchers.Main.immediate` lazily in [bind] (NOT eagerly at construction — that would touch
|
||||
* `Dispatchers.Main` and throw in a `runTest` without `setMain`). A JVM test injects a virtual-time
|
||||
* dispatcher so the holder lifecycle drives off `Main`. `internal` — not frozen surface.
|
||||
*/
|
||||
internal var mainDispatcher: CoroutineDispatcher? = null
|
||||
|
||||
/**
|
||||
* Emulator factory seam. Production builds a real [RemoteTerminalSession]; a JVM test substitutes a
|
||||
* virtual-time one so the holder lifecycle test never spins a real append thread. `internal`.
|
||||
*/
|
||||
internal var remoteSessionFactory:
|
||||
(engineSend: (ClientMessage) -> Unit, onTitle: (String) -> Unit) -> RemoteTerminalSession =
|
||||
{ engineSend, onTitle -> RemoteTerminalSession(engineSend = engineSend, onTitleChanged = onTitle) }
|
||||
|
||||
/**
|
||||
* Bind the session for [endpoint], or REUSE the survivor across a config change. Called by the
|
||||
* terminal screen on (re)composition. [sessionId] null spawns a new session (with [cwd]); a
|
||||
* non-null id re-attaches for ring replay. Idempotent for the SAME (endpoint, sessionId): a second
|
||||
* call while that session is live returns the existing controller (config-change re-bind) and never
|
||||
* spawns a duplicate engine or emulator.
|
||||
*
|
||||
* A holder is **single-session-for-life**: a re-bind whose (endpoint, sessionId) does NOT match the
|
||||
* bound one throws [IllegalStateException] — a nav-scoping programming error, since the short-circuit
|
||||
* would otherwise silently ignore the new args and show the WRONG session. A different session must
|
||||
* get its own nav-scoped holder.
|
||||
*
|
||||
* Returns the controller **UN-STARTED**: every consumer (output + banner + gate mailboxes) is already
|
||||
* registered EAGERLY here, and the terminal screen calls `controller.start()` afterwards. This
|
||||
* register-before-start ordering guarantees no `attached`/replay frame is dropped and no control
|
||||
* event is split (FIX 1).
|
||||
*/
|
||||
public fun bind(
|
||||
endpoint: HostEndpoint,
|
||||
sessionId: String? = null,
|
||||
cwd: String? = null,
|
||||
): TerminalSessionControllerImpl {
|
||||
val requested = BoundKey(endpoint, sessionId)
|
||||
controllerImpl?.let { existing ->
|
||||
check(requested == boundKey) {
|
||||
"RetainedSessionHolder is single-session-for-life: already bound to $boundKey but bind() " +
|
||||
"requested $requested. A different session needs a new nav-scoped holder."
|
||||
}
|
||||
return existing // same key → config-change reuse of the live engine/emulator/presenters
|
||||
}
|
||||
val scope = engineFactory.newConfinedScope()
|
||||
val bus = EventBus() // one bus per engine, lives on the engine's confined scope
|
||||
val newEngine = engineFactory.create(endpoint, scope, sessionId, cwd)
|
||||
val base = DefaultTerminalSessionController(newEngine, bus, scope)
|
||||
// FIX 3: the render controller (+ its emulator) is owned HERE; the output collector runs in the
|
||||
// confined [scope] on [mainDispatcher], so it dies with the session, not the composition (§6.6).
|
||||
val controller = TerminalSessionControllerImpl(
|
||||
base = base,
|
||||
onSanitizedTitle = { titleState.value = it },
|
||||
outputCollectorScope = scope,
|
||||
outputDispatcher = mainDispatcher ?: Dispatchers.Main.immediate,
|
||||
remoteSessionFactory = remoteSessionFactory,
|
||||
)
|
||||
// FIX 1/FIX 4: each retained consumer registers its OWN control mailbox EAGERLY (before start)
|
||||
// and folds in the confined [scope] so its state survives a config change.
|
||||
val gate = GateViewModel(controller) // registers the gate mailbox at construction
|
||||
gate.bind(scope)
|
||||
val bannerEvents = controller.controlEvents() // registers the banner mailbox
|
||||
scope.launch { bannerEvents.collect { bannerFlow.value = bannerModel(bannerFlow.value, it) } }
|
||||
|
||||
engine = newEngine
|
||||
engineScope = scope
|
||||
eventBus = bus
|
||||
controllerImpl = controller
|
||||
gateVm = gate
|
||||
boundKey = requested
|
||||
// DO NOT start here — the terminal screen calls controller.start() after obtaining the presenters.
|
||||
return controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive from the Activity's `onStop`. [isChangingConfigurations] (`Activity.isChangingConfigurations`)
|
||||
* separates a rotation/config change (survive) from a real background (clean detach + generation bump).
|
||||
*/
|
||||
public fun onStop(isChangingConfigurations: Boolean) {
|
||||
if (isChangingConfigurations) return // config change → the survivor is re-bound on recreate
|
||||
teardown(bumpGeneration = true) // real background → clean detach; server PTY survives
|
||||
}
|
||||
|
||||
/** Genuine destruction (nav pop / not a config change): detach cleanly; the VM won't be re-bound. */
|
||||
override fun onCleared() {
|
||||
teardown(bumpGeneration = false)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun teardown(bumpGeneration: Boolean) {
|
||||
val liveEngine = engine ?: return
|
||||
val scope = engineScope
|
||||
val bus = eventBus
|
||||
val impl = controllerImpl
|
||||
// (1) Clean detach FIRST — the server-side PTY keeps running (invariant #2, §6.6 follow-up).
|
||||
val closeJob = liveEngine.close()
|
||||
// (2) AWAIT the clean close (the RFC-6455 `1000` frame handed to the transport writer), THEN
|
||||
// release the per-session EventBus mailboxes, and ONLY THEN cancel the confinement scope (FIX 5).
|
||||
// Joining makes close-before-cancel structural rather than relying on implicit same-dispatcher
|
||||
// FIFO — the socket is never leaked to a timeout death; closing the bus here (session end, not a
|
||||
// config-change cancel) frees the mailboxes exactly when the session dies.
|
||||
scope?.launch {
|
||||
closeJob.join()
|
||||
bus?.close()
|
||||
scope.cancel()
|
||||
}
|
||||
// Release the emulator's append thread (session end); the output collector then no-ops on the
|
||||
// closed command channel until the scope cancel above stops it.
|
||||
impl?.remoteSession?.close()
|
||||
engine = null
|
||||
engineScope = null
|
||||
eventBus = null
|
||||
controllerImpl = null
|
||||
gateVm = null
|
||||
boundKey = null
|
||||
// Reset the retained presenters so a generation-bump rebuild starts clean.
|
||||
titleState.value = ""
|
||||
bannerFlow.value = BannerModel.Hidden
|
||||
if (bumpGeneration) generationState.intValue += 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||
import wang.yaojia.webterm.session.Adopted
|
||||
import wang.yaojia.webterm.session.Exited
|
||||
import wang.yaojia.webterm.session.SessionEvent
|
||||
|
||||
/**
|
||||
* # SessionActivityBridge (A29) — drives the [LastSessionStore] off the engine event stream.
|
||||
*
|
||||
* The lifecycle half of the continue-last-session cold-start UX (plan §1, §5 A29). One bridge is minted
|
||||
* PER SESSION, bound to the [hostId] the session dials (like [EventBus], it is NOT an app singleton and
|
||||
* has no Hilt binding — it is constructed inside the session unit and dies with it). It observes the
|
||||
* session's control-event stream and folds exactly two lifecycle events into the store:
|
||||
*
|
||||
* - **`Adopted` → SET** — persist the SERVER-ISSUED id ([Adopted.sessionId]). The server always adopts
|
||||
* its own id (a reconnect may hand back a new one), so the LATEST adopted id is always the one to
|
||||
* offer as "continue last"; re-adopting simply overwrites.
|
||||
* - **`Exited` → CLEAR** — a dead shell must NOT be offered as "continue last" (else cold-start would
|
||||
* silently spawn a brand-new session), so the shell exiting clears the stored id ([LastSessionStore]).
|
||||
*
|
||||
* Every OTHER event — including `Connection(Closed)` (a mere DETACH: the server-side PTY keeps running,
|
||||
* so the session is STILL continuable) — leaves the store untouched. Only a real `Exited` clears it.
|
||||
*
|
||||
* Keyed by [hostId] so distinct hosts never clobber each other's last-session pointer.
|
||||
*
|
||||
* ### Purity / testability
|
||||
* PURE Kotlin (coroutines only, no Android import) so the set-on-adopted / clear-on-exited behaviour is
|
||||
* unit-testable under `runTest` by driving [observe] with a `flowOf(...)` and a real
|
||||
* [InMemoryLastSessionStore][wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore] double.
|
||||
*/
|
||||
public class SessionActivityBridge(
|
||||
private val lastSessionStore: LastSessionStore,
|
||||
private val hostId: String,
|
||||
) {
|
||||
/**
|
||||
* Observe [events] for the whole session lifetime, applying each to the [LastSessionStore]. Returns
|
||||
* when [events] completes (the engine closed its stream) or the collecting scope is cancelled. The
|
||||
* caller launches this on the session's scope and collects the controller's control-event stream.
|
||||
*/
|
||||
public suspend fun observe(events: Flow<SessionEvent>) {
|
||||
events.collect { record(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a single [event] into the store: `Adopted` sets the server-issued id, `Exited` clears it, and
|
||||
* every other event (output / connection / gate / telemetry / digest) is a no-op.
|
||||
*/
|
||||
public suspend fun record(event: SessionEvent) {
|
||||
when (event) {
|
||||
is Adopted -> lastSessionStore.setLastSessionId(event.sessionId, hostId)
|
||||
is Exited -> lastSessionStore.setLastSessionId(null, hostId)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import wang.yaojia.webterm.session.SessionEngine
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import wang.yaojia.webterm.wire.TimelineEvent
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Mints a [SessionEngine] per session over the ONE shared [TermTransport] (A15 wiring; plan §0
|
||||
* invariant #4, §6.6). A [SessionEngine] is per-session (its [HostEndpoint] + confinement scope are
|
||||
* fixed at creation), so it is built on demand by the [RetainedSessionHolder], never a singleton.
|
||||
*
|
||||
* ### The transport is [Lazy] (FIX 3 — no AndroidKeyStore/Tink I/O on the injection/Main thread)
|
||||
* Resolving [TermTransport] builds the shared `OkHttpClient`, which reads the mTLS SSL material — a
|
||||
* path that first-touches AndroidKeyStore + Tink (slow, MUST be off `Main`). Injecting a
|
||||
* `dagger.Lazy<TermTransport>` means merely constructing this factory (and therefore `AppEnvironment`)
|
||||
* does NOT build the client. The heavy resolution happens exactly once, off `Main`, in [warm] (called
|
||||
* from `AppEnvironment.warmUp()` on `Dispatchers.IO`); afterwards [create] resolves the already-built
|
||||
* singleton instantly.
|
||||
*
|
||||
* ### Confinement (invariant #4)
|
||||
* [newConfinedScope] creates the engine's `limitedParallelism(1)` dispatcher — the single-thread
|
||||
* confinement that replaces the Swift actor. Each engine gets its OWN scope so the holder can cancel
|
||||
* it on teardown WITHOUT touching other sessions; ALL engine state is then touched only on that
|
||||
* dispatcher. The scope's root is a [SupervisorJob] so one failed child never tears the engine down.
|
||||
*/
|
||||
@Singleton
|
||||
public class SessionEngineFactory @Inject constructor(
|
||||
private val transport: Lazy<TermTransport>,
|
||||
) {
|
||||
/**
|
||||
* Test seam: builds the engine's confinement scope. Production creates a real
|
||||
* `Dispatchers.Default.limitedParallelism(1)` scope; a unit test substitutes a virtual-time
|
||||
* `TestDispatcher` so the holder/engine lifecycle runs deterministically under `runTest`.
|
||||
* `internal` — not part of the frozen public surface.
|
||||
*/
|
||||
internal var confinedScopeFactory: () -> CoroutineScope = {
|
||||
CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob())
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh single-thread confinement scope for one engine. Owned by the caller
|
||||
* ([RetainedSessionHolder]) which cancels it AFTER `engine.close()` on teardown (§6.6).
|
||||
*/
|
||||
public fun newConfinedScope(): CoroutineScope = confinedScopeFactory()
|
||||
|
||||
/**
|
||||
* Resolve the shared [TermTransport] (→ builds the shared `OkHttpClient` → first mTLS touch) so the
|
||||
* slow AndroidKeyStore/Tink I/O runs on the CALLER's thread. Call ONLY from a background
|
||||
* dispatcher (`AppEnvironment.warmUp()` on `Dispatchers.IO`), never on `Main`.
|
||||
*/
|
||||
public fun warm() {
|
||||
transport.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a [SessionEngine] for [endpoint] on [scope]. [sessionId] null → a NEW session (with [cwd]
|
||||
* for new-session-in-cwd); a non-null id re-attaches so the server replays the ring buffer.
|
||||
* [awayTimeline] supplies the once-per-reconnect digest source (A21 wires it to `ApiClient.events`
|
||||
* for the adopted id); it defaults to empty so the freeze stays honest — the engine still emits an
|
||||
* (empty, UI-suppressed) digest.
|
||||
*
|
||||
* Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read.
|
||||
*/
|
||||
public fun create(
|
||||
endpoint: HostEndpoint,
|
||||
scope: CoroutineScope,
|
||||
sessionId: String? = null,
|
||||
cwd: String? = null,
|
||||
awayTimeline: suspend (sinceMs: Long) -> List<TimelineEvent> = { emptyList() },
|
||||
): SessionEngine =
|
||||
SessionEngine(
|
||||
transport = transport.get(),
|
||||
endpoint = endpoint,
|
||||
scope = scope,
|
||||
initialSessionId = sessionId,
|
||||
initialCwd = cwd,
|
||||
awayTimeline = awayTimeline,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import wang.yaojia.webterm.session.SessionEngine
|
||||
import wang.yaojia.webterm.session.SessionEvent
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
|
||||
/**
|
||||
* The FROZEN surface AW4 / A21 depend on to drive ONE terminal session (plan §5 A15, §6.2/§6.3). It
|
||||
* owns a [SessionEngine]'s lifecycle and is the single seam between the terminal UI (`:terminal-view`,
|
||||
* A16) and the engine:
|
||||
* - **UI→engine:** [sendInput] / [resize] / [notifyForegrounded] / [decideGate] map straight onto
|
||||
* `engine.send`/`notifyForegrounded`/`decideGate` (invariant #4 — the only UI→engine calls).
|
||||
* - **engine→UI:** [controlEvents] is the cockpit's control-event stream (banner / gate / telemetry /
|
||||
* digest); [output] is the decoded terminal-byte stream the emulator consumes. The two are DISJOINT
|
||||
* typed sub-streams of [EventBus] (FIX 4) — neither consumer buffers what it does not need.
|
||||
*
|
||||
* ### Control events are MULTI-SUBSCRIBABLE (FIX 1 — no split across consumers)
|
||||
* The reconnect banner AND the [GateViewModel][wang.yaojia.webterm.viewmodels.GateViewModel] (AND later
|
||||
* the A28 timeline) each need the FULL control-event stream. A single shared `Channel.receiveAsFlow()`
|
||||
* mailbox is a MAILBOX, not a broadcast: two competing `collect()` on it would SPLIT events (each event
|
||||
* reaches only one collector). So control events are exposed as a FUNCTION, not a single `val`: EACH
|
||||
* call to [controlEvents] mints its OWN per-consumer [EventBus] mailbox (the bus already registers a
|
||||
* fresh unbounded channel per call), so every consumer receives every control event, losslessly and
|
||||
* without stalling the others (R10). [output], by contrast, stays a SINGLE `val` stream: it has exactly
|
||||
* ONE consumer (the emulator feed), and the multi-MB replay must never be fanned out twice.
|
||||
*
|
||||
* ### START is decoupled from bind (FIX 2 — no pre-subscription event drop)
|
||||
* `RetainedSessionHolder.bind()` constructs and returns this controller UN-STARTED, having already
|
||||
* registered every consumer's mailbox: [output] registers ONE mailbox at construction, and the holder
|
||||
* calls [controlEvents] once per retained consumer (banner + gate) — all EAGERLY, before [start].
|
||||
* ALL consumers therefore MUST subscribe (call [controlEvents] / collect [output]) BEFORE [start];
|
||||
* [start] then wires the engine→[EventBus] pump AND `engine.start()`. Nothing is published to the bus
|
||||
* before [start], and every mailbox already exists, so the `attached` frame + the multi-MB ring-buffer
|
||||
* replay can never be dropped, and no event is split.
|
||||
*
|
||||
* ### Boundary note (plan §3 / §6.2 — decode lives HERE, not in `:terminal-view`)
|
||||
* `:terminal-view` depends only on `:wire-protocol` and consumes raw `ByteArray`. The
|
||||
* `SessionEvent.Output(String) → ByteArray` (UTF-8) decode therefore happens on the [EventBus]
|
||||
* [output][EventBus.outputBytes] sub-stream (off `Main` via `flowOn`), keeping the terminal module free
|
||||
* of any `SessionEvent`/`:session-core` edge.
|
||||
*
|
||||
* A21 writes the full implementation (pendingOutput ordering, banner precedence, new-session-in-cwd,
|
||||
* privacy shade). [DefaultTerminalSessionController] is the minimal skeleton that freezes the shape.
|
||||
*/
|
||||
public interface TerminalSessionController {
|
||||
/**
|
||||
* The cockpit's control-event stream: every NON-`Output` event (connection/gate/telemetry/digest/
|
||||
* exit). Each call mints its OWN per-consumer [EventBus] mailbox (FIX 1), so multiple consumers
|
||||
* (banner + gate + A28 timeline) each receive every control event without splitting — a single
|
||||
* shared `val` mailbox would let two `collect()` steal events from each other. The mailbox is
|
||||
* registered EAGERLY at call time, so callers MUST call this BEFORE [start] (no pre-subscription
|
||||
* drop). Every returned flow is independent, lossless, and re-collectable across a config-change
|
||||
* cancel (the mailbox lives for the session, not the collection).
|
||||
*/
|
||||
public fun controlEvents(): Flow<SessionEvent>
|
||||
|
||||
/**
|
||||
* The decoded terminal output stream: [SessionEvent.Output] frames only, UTF-8-decoded to bytes on
|
||||
* `Dispatchers.Default` (off `Main`), fed verbatim to the emulator (`:terminal-view`). All other
|
||||
* event types are routed to [events], never buffered here.
|
||||
*/
|
||||
public val output: Flow<ByteArray>
|
||||
|
||||
/**
|
||||
* Begin connecting (attach-first): start the engine→[EventBus] pump AND `engine.start()`. MUST be
|
||||
* called AFTER the screen has wired its [events]/[output] collectors (FIX 2b). Idempotent — a
|
||||
* second call is a no-op.
|
||||
*/
|
||||
public fun start()
|
||||
|
||||
/** Typed / key-bar bytes → `engine.send(Input(data))`, passed through VERBATIM (invariant #1/#9). */
|
||||
public fun sendInput(data: String)
|
||||
|
||||
/** Latest-writer-wins PTY sizing → `engine.send(Resize(cols, rows))` (plan §6.4). */
|
||||
public fun resize(cols: Int, rows: Int)
|
||||
|
||||
/**
|
||||
* The app/pane returned to the foreground → reclaim full-screen (re-send dims) or connect-now
|
||||
* without resetting the back-off ladder (plan §6.4/§6.6). Null dims leave the remembered size.
|
||||
*/
|
||||
public fun notifyForegrounded(cols: Int?, rows: Int?)
|
||||
|
||||
/**
|
||||
* Resolve a held gate with the two-line epoch stale-guard: a stale [epoch] is dropped so a slow
|
||||
* tap never approves the NEXT gate (`GateTracker.canDecide`, plan §3.2). [message] is the
|
||||
* affordance's wire message (`GateState.Affordance.clientMessage`).
|
||||
*/
|
||||
public fun decideGate(epoch: Int, message: ClientMessage)
|
||||
|
||||
/** Detach: close the WS (the server-side PTY keeps running). NEVER a kill (invariant #2). */
|
||||
public fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal skeleton [TerminalSessionController] over a single [SessionEngine] + its PER-SESSION
|
||||
* [EventBus] (FIX 1). It establishes the seam for A21 (which wraps it via [TerminalSessionControllerImpl]):
|
||||
* registers the SINGLE [output] mailbox at construction and mints a FRESH control mailbox per
|
||||
* [controlEvents] call, and on [start] wires the engine→bus pump BEFORE `engine.start()` so the replay
|
||||
* is never dropped and no control event is split (FIX 1/FIX 2).
|
||||
*
|
||||
* [fanOutScope] runs the engine→UI pump; it must live as long as the session (the
|
||||
* [RetainedSessionHolder] passes the engine's confinement scope). The pump completes on its own when
|
||||
* the engine closes its event channel (detach/exit) or when [fanOutScope] is cancelled.
|
||||
*/
|
||||
public class DefaultTerminalSessionController(
|
||||
private val engine: SessionEngine,
|
||||
private val eventBus: EventBus,
|
||||
private val fanOutScope: CoroutineScope,
|
||||
) : TerminalSessionController {
|
||||
|
||||
// The SINGLE output mailbox is registered EAGERLY at construction (FIX 2a) — exactly one consumer
|
||||
// (the emulator feed), so the multi-MB replay is never fanned out twice. Control events are NOT a
|
||||
// single `val`: each [controlEvents] call mints its own mailbox (FIX 1) so banner + gate + A28
|
||||
// timeline never split the stream. Every mailbox lives for the SESSION (bus) lifetime, not a
|
||||
// collection's: a config-change cancel leaves it re-collectable, an uncollected mailbox simply
|
||||
// buffers, and all are released by EventBus.close() in RetainedSessionHolder.teardown().
|
||||
override val output: Flow<ByteArray> = eventBus.outputBytes()
|
||||
|
||||
// FIX 1: a FRESH per-consumer mailbox on every call (the bus mints one each time). Callers MUST
|
||||
// call this BEFORE start() — the mailbox registers eagerly, so nothing published after start drops.
|
||||
override fun controlEvents(): Flow<SessionEvent> = eventBus.controlEvents()
|
||||
|
||||
// Guards start() idempotency. The intended caller is the UI thread (the terminal screen), but
|
||||
// @Volatile removes that unstated single-thread precondition so a stray off-thread start() cannot
|
||||
// observe a stale false and double-start the engine.
|
||||
@Volatile
|
||||
private var started: Boolean = false
|
||||
private var pumpJob: Job? = null
|
||||
|
||||
override fun start() {
|
||||
if (started) return
|
||||
started = true
|
||||
// Connect the engine→bus pump FIRST (mailboxes already registered), THEN start the engine, so
|
||||
// the 'attached' frame + multi-MB replay land in the already-registered per-consumer mailboxes.
|
||||
pumpJob = eventBus.connect(fanOutScope, engine.events)
|
||||
engine.start()
|
||||
}
|
||||
|
||||
override fun sendInput(data: String): Unit = engine.send(ClientMessage.Input(data))
|
||||
|
||||
override fun resize(cols: Int, rows: Int): Unit = engine.send(ClientMessage.Resize(cols, rows))
|
||||
|
||||
override fun notifyForegrounded(cols: Int?, rows: Int?): Unit =
|
||||
engine.notifyForegrounded(cols, rows)
|
||||
|
||||
override fun decideGate(epoch: Int, message: ClientMessage): Unit =
|
||||
engine.decideGate(epoch, message)
|
||||
|
||||
// Fire-and-forget detach; the awaitable teardown path is RetainedSessionHolder.teardown() (FIX 5).
|
||||
override fun close() {
|
||||
engine.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.session.TitleSanitizer
|
||||
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
|
||||
/**
|
||||
* # TerminalSessionControllerImpl (A21) — the real terminal-session controller.
|
||||
*
|
||||
* A15 froze the [TerminalSessionController] INTERFACE and shipped [DefaultTerminalSessionController] as
|
||||
* a minimal skeleton that owns the [SessionEngine][wang.yaojia.webterm.session.SessionEngine]↔[EventBus]
|
||||
* ↔UI wiring (start/pump/`output`/`events`/`sendInput`/`resize`/`notifyForegrounded`/`decideGate`/`close`).
|
||||
* A21 adds the terminal-render half: the [RemoteTerminalSession] (the forked Termux emulator), the
|
||||
* output→emulator feed, and the OSC-title → [TitleSanitizer] wiring.
|
||||
*
|
||||
* ### Config-survival: OWNED BY THE HOLDER, not the composition (§6.6 / FIX 3)
|
||||
* The [RemoteTerminalSession] (emulator + scrollback + scroll position) MUST survive a config change
|
||||
* (rotation/fold): §6.6 requires the emulator re-bind to a recreated `AndroidView` with NO replay
|
||||
* round-trip. So this whole controller is constructed and OWNED by the config-surviving
|
||||
* [RetainedSessionHolder] (its `viewModelScope`), NEVER `remember`ed in the composition (a
|
||||
* composition-remembered emulator dies on every rotation). `TerminalScreen` obtains it FROM the holder
|
||||
* and re-binds the surviving emulator on rotation; only a genuine background (generation bump) rebuilds
|
||||
* a fresh emulator that replays the ring.
|
||||
*
|
||||
* ### Why it WRAPS the skeleton (a constraint-driven, recorded shape)
|
||||
* The engine + per-session [EventBus] live in the holder alongside this controller. The real impl layers
|
||||
* the terminal-render wiring ON TOP of the [base] [DefaultTerminalSessionController] via interface
|
||||
* delegation (`by base`): every lifecycle/UI→engine call still routes through the skeleton to the
|
||||
* engine, and A21 only ADDS the two new responsibilities the plan assigns it:
|
||||
* - **`output` → [RemoteTerminalSession.feedRemote]** — the decoded terminal-byte stream (`base.output`
|
||||
* is already `Output→ByteArray` UTF-8-decoded off `Main` by [EventBus.outputBytes]) is fed verbatim
|
||||
* to the emulator. The collector is wired in [start] BEFORE `base.start()`, so the `attached` frame +
|
||||
* multi-MB ring replay are never dropped (plan §6.2), and it runs on `Main.immediate` (§6.6).
|
||||
* - **OSC title → [TitleSanitizer]** — `:terminal-view` has no `:session-core` edge, so the raw title
|
||||
* from the emulator is sanitized HERE (in `:app`) before it ever reaches the UI (plan §6.5 / §8).
|
||||
*
|
||||
* Emulator-originated bytes (DA/DSR replies, resize) flow back out through [routeEmulatorMessage] into
|
||||
* `base` → the engine's ordered send pump, so typing, key-bar taps and emulator replies share one wire
|
||||
* order (plan §6.3).
|
||||
*
|
||||
* @param base the holder-owned skeleton controller (a [DefaultTerminalSessionController]), UN-STARTED.
|
||||
* @param onSanitizedTitle receives the already-[TitleSanitizer]-sanitized OSC 0/2 title (inert display).
|
||||
* @param outputCollectorScope the scope the `output`→`feedRemote` collector runs in. The holder passes
|
||||
* its config-surviving engine scope, so the collector is cancelled with the SESSION (not the
|
||||
* composition) — a recompose never drops a frame (§6.6) and the surviving emulator keeps consuming.
|
||||
* @param outputDispatcher where the `output`→`feedRemote` collector runs (default `Main.immediate`,
|
||||
* §6.6; injected as a virtual-time test dispatcher so the holder lifecycle is JVM-testable off `Main`).
|
||||
* @param remoteSessionFactory inject seam for the emulator-backed [RemoteTerminalSession] — the holder
|
||||
* supplies it (defaulting to a real emulator) so a JVM test can substitute a virtual-time session.
|
||||
*/
|
||||
public class TerminalSessionControllerImpl(
|
||||
private val base: TerminalSessionController,
|
||||
onSanitizedTitle: (String) -> Unit,
|
||||
private val outputCollectorScope: CoroutineScope,
|
||||
private val outputDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
|
||||
remoteSessionFactory: (engineSend: (ClientMessage) -> Unit, onTitle: (String) -> Unit) -> RemoteTerminalSession =
|
||||
{ engineSend, onTitle -> RemoteTerminalSession(engineSend = engineSend, onTitleChanged = onTitle) },
|
||||
) : TerminalSessionController by base {
|
||||
|
||||
/**
|
||||
* The forked Termux emulator for this session (no local process). The screen puts it on-screen via
|
||||
* `attachView` and drives `updateSize`; A21 feeds it the remote output and routes its replies out.
|
||||
*/
|
||||
public val remoteSession: RemoteTerminalSession =
|
||||
remoteSessionFactory(::routeEmulatorMessage) { raw -> onSanitizedTitle(TitleSanitizer.sanitize(raw)) }
|
||||
|
||||
private var outputWired: Boolean = false
|
||||
|
||||
/**
|
||||
* Wire `output` → [RemoteTerminalSession.feedRemote] on `Main.immediate` (idempotent), THEN start the
|
||||
* engine via `base.start()`. Wiring the feed before the engine starts guarantees the `attached` frame
|
||||
* and the multi-MB replay land in the already-registered [EventBus] output mailbox (plan §6.2).
|
||||
*/
|
||||
override fun start() {
|
||||
if (!outputWired) {
|
||||
outputWired = true
|
||||
outputCollectorScope.launch(outputDispatcher) {
|
||||
base.output.collect { remoteSession.feedRemote(it) }
|
||||
}
|
||||
}
|
||||
base.start()
|
||||
}
|
||||
|
||||
/** Detach (base) AND release the emulator's append thread. Idempotent — safe to call more than once. */
|
||||
override fun close() {
|
||||
base.close()
|
||||
remoteSession.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* The emulator's outbound sink → the engine's ordered send pump (via [base]). The Termux
|
||||
* `TerminalOutput` only ever emits [ClientMessage.Input] (DA/DSR/mouse replies) and, from
|
||||
* `updateSize`, [ClientMessage.Resize]; approve/reject/attach never originate here.
|
||||
*/
|
||||
private fun routeEmulatorMessage(message: ClientMessage) {
|
||||
when (message) {
|
||||
is ClientMessage.Input -> base.sendInput(message.data)
|
||||
is ClientMessage.Resize -> base.resize(message.cols, message.rows)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
import com.termux.terminal.TerminalOutput
|
||||
import com.termux.terminal.TerminalSession
|
||||
import com.termux.terminal.TerminalSessionClient
|
||||
import com.termux.terminal.TextStyle
|
||||
import wang.yaojia.webterm.api.models.SessionPreview
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* The off-screen preview painter (plan §6.7) — the device-verified half of the thumbnail pipeline.
|
||||
*
|
||||
* Instantiates a [TerminalEmulator] with NO `TerminalView` and NO subprocess (the same "remove the JNI
|
||||
* process, keep the VT parser + `TerminalBuffer`" seam A16 proved), appends the preview bytes, then
|
||||
* rasterises the buffer cell-by-cell onto a `Bitmap`-backed [Canvas] with a monospace [Paint] — a direct
|
||||
* port of Termux `TerminalRenderer.render`'s per-cell draw: for each cell, fill its background rect then
|
||||
* stroke its glyph in the cell's foreground colour, decoding fg/bg/effect from the cell's `TextStyle`
|
||||
* (24-bit truecolor / 256 / ANSI, with the bold→bright bump and inverse-video swap).
|
||||
*
|
||||
* `TerminalRenderer`'s own `mFontWidth`/`mFontLineSpacing` are package-private, so this computes its own
|
||||
* cell metrics from the [Paint] (`ceil(measureText("X"))` and the ascent-to-descent span), exactly as
|
||||
* `TerminalRenderer` derives them.
|
||||
*
|
||||
* android.graphics is stubbed in JVM unit tests, so this class is exercised by device QA (plan §7,
|
||||
* `:terminal-view` instrumented "thumbnail rasterisation non-null"); the pipeline policy around it is
|
||||
* JVM-tested via the [ThumbnailRasterizer] seam.
|
||||
*/
|
||||
public class CanvasThumbnailRasterizer(
|
||||
private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX,
|
||||
typeface: Typeface = Typeface.MONOSPACE,
|
||||
) : ThumbnailRasterizer {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
this.typeface = typeface
|
||||
textSize = fontSizePx
|
||||
}
|
||||
private val cellWidth: Int = ceil(paint.measureText("X").toDouble()).toInt().coerceAtLeast(1)
|
||||
private val cellHeight: Int = (paint.fontMetricsInt.descent - paint.fontMetricsInt.ascent).coerceAtLeast(1)
|
||||
private val baselineOffset: Int = -paint.fontMetricsInt.ascent
|
||||
|
||||
override fun rasterize(preview: SessionPreview): Bitmap {
|
||||
val cols = preview.cols.coerceIn(1, MAX_COLS)
|
||||
val rows = preview.rows.coerceIn(1, MAX_ROWS)
|
||||
val emulator = TerminalEmulator(NoOpOutput, cols, rows, TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN, NoOpClient)
|
||||
val bytes = preview.data.toByteArray(Charsets.UTF_8)
|
||||
emulator.append(bytes, bytes.size)
|
||||
|
||||
val bitmap = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val palette = emulator.mColors.mCurrentColors
|
||||
val defaultBg = palette[TextStyle.COLOR_INDEX_BACKGROUND]
|
||||
canvas.drawColor(defaultBg)
|
||||
for (row in 0 until rows) {
|
||||
drawRow(canvas, emulator, row, cols, palette, defaultBg)
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
override fun placeholder(): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(cellWidth, cellHeight, Bitmap.Config.ARGB_8888)
|
||||
bitmap.eraseColor(PLACEHOLDER_COLOR)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun drawRow(canvas: Canvas, emulator: TerminalEmulator, row: Int, cols: Int, palette: IntArray, defaultBg: Int) {
|
||||
val screen = emulator.screen
|
||||
val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row))
|
||||
val text = line.mText
|
||||
val top = (row * cellHeight).toFloat()
|
||||
val baseline = (row * cellHeight + baselineOffset).toFloat()
|
||||
for (col in 0 until cols) {
|
||||
val style = line.getStyle(col)
|
||||
val effect = TextStyle.decodeEffect(style)
|
||||
val bold = (effect and (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0
|
||||
val inverse = (effect and TextStyle.CHARACTER_ATTRIBUTE_INVERSE) != 0
|
||||
val invisible = (effect and TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) != 0
|
||||
var fg = resolveColor(TextStyle.decodeForeColor(style), palette, boldBright = bold)
|
||||
var bg = resolveColor(TextStyle.decodeBackColor(style), palette, boldBright = false)
|
||||
if (inverse) { val swap = fg; fg = bg; bg = swap }
|
||||
|
||||
val left = (col * cellWidth).toFloat()
|
||||
if (bg != defaultBg) {
|
||||
paint.color = bg
|
||||
paint.style = Paint.Style.FILL
|
||||
canvas.drawRect(left, top, left + cellWidth, top + cellHeight, paint)
|
||||
}
|
||||
val start = line.findStartOfColumn(col)
|
||||
val end = line.findStartOfColumn(col + 1)
|
||||
if (!invisible && end > start) {
|
||||
paint.color = fg
|
||||
paint.isFakeBoldText = bold
|
||||
canvas.drawText(text, start, end - start, left, baseline, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a decoded colour to an ARGB int: a truecolor value (its `0xff000000` marker set) passes
|
||||
* through; an indexed value is looked up in the emulator's live [palette], with a bold foreground in
|
||||
* the first 8 ANSI colours bumped to its bright (8..15) variant — matching `TerminalRenderer`.
|
||||
*/
|
||||
private fun resolveColor(color: Int, palette: IntArray, boldBright: Boolean): Int {
|
||||
if ((color and TRUECOLOR_MASK) == TRUECOLOR_MASK) return color
|
||||
val index = if (boldBright && color in 0..7) color + 8 else color
|
||||
return palette[index]
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/** Default glyph size for a thumbnail (px). Small — the card downscales anyway. */
|
||||
public const val DEFAULT_FONT_SIZE_PX: Float = 12f
|
||||
|
||||
/** Clamp the off-screen grid so a rogue preview geometry can't allocate an enormous bitmap. */
|
||||
public const val MAX_COLS: Int = 400
|
||||
public const val MAX_ROWS: Int = 200
|
||||
|
||||
/** Opaque dark-grey placeholder for the fetch/render-failure fallback. */
|
||||
public const val PLACEHOLDER_COLOR: Int = -0xdfdfe0 // 0xFF202020
|
||||
|
||||
/** A truecolor `TextStyle` value carries the 0xff000000 marker in its high byte. */
|
||||
private const val TRUECOLOR_MASK: Int = -0x1000000 // 0xFF000000
|
||||
}
|
||||
}
|
||||
|
||||
/** Off-screen emulator sink: the preview emulator produces no wire output and needs no delegates. */
|
||||
private object NoOpOutput : TerminalOutput() {
|
||||
override fun write(data: ByteArray?, offset: Int, count: Int) = Unit
|
||||
override fun titleChanged(oldTitle: String?, newTitle: String?) = Unit
|
||||
override fun onCopyTextToClipboard(text: String?) = Unit
|
||||
override fun onPasteTextFromClipboard() = Unit
|
||||
override fun onBell() = Unit
|
||||
override fun onColorsChanged() = Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op [TerminalSessionClient] for the off-screen emulator (the constructor requires a non-null client;
|
||||
* Termux calls it on rare diagnostic escape paths without a null-check). Logs NOTHING — preview bytes are
|
||||
* attacker-influenced and must never reach logcat.
|
||||
*/
|
||||
private object NoOpClient : TerminalSessionClient {
|
||||
override fun onTextChanged(changedSession: TerminalSession?) = Unit
|
||||
override fun onTitleChanged(changedSession: TerminalSession?) = Unit
|
||||
override fun onSessionFinished(finishedSession: TerminalSession?) = Unit
|
||||
override fun onCopyTextToClipboard(session: TerminalSession?, text: String?) = Unit
|
||||
override fun onPasteTextFromClipboard(session: TerminalSession?) = Unit
|
||||
override fun onBell(session: TerminalSession?) = Unit
|
||||
override fun onColorsChanged(session: TerminalSession?) = Unit
|
||||
override fun onTerminalCursorStateChange(state: Boolean) = Unit
|
||||
override fun getTerminalCursorStyle(): Int = TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK
|
||||
override fun logError(tag: String?, message: String?) = Unit
|
||||
override fun logWarn(tag: String?, message: String?) = Unit
|
||||
override fun logInfo(tag: String?, message: String?) = Unit
|
||||
override fun logDebug(tag: String?, message: String?) = Unit
|
||||
override fun logVerbose(tag: String?, message: String?) = Unit
|
||||
override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) = Unit
|
||||
override fun logStackTrace(tag: String?, e: Exception?) = Unit
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.LruCache
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||
import wang.yaojia.webterm.api.models.SessionPreview
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Off-screen terminal-preview rasterisation for the session-list / manage-page thumbnails (plan §6.7).
|
||||
*
|
||||
* A read-only [ApiClient] preview fetch (`GET /live-sessions/:id/preview`) over the ONE shared mTLS
|
||||
* OkHttp client is fed into an off-screen [com.termux.terminal.TerminalEmulator] (no `TerminalView`, no
|
||||
* subprocess) and rasterised cell-by-cell onto a `Bitmap` — see [CanvasThumbnailRasterizer]. This class
|
||||
* owns the *policy* around that expensive work; the actual `Canvas`/`Bitmap` draw is delegated to the
|
||||
* [ThumbnailRasterizer] seam so this policy (cache, concurrency, dedup, failure fallback) stays
|
||||
* JVM-unit-testable while the pixel raster is verified on-device (android.graphics is stubbed off-device).
|
||||
*
|
||||
* ### The four policies (plan §6.7)
|
||||
* - **Cache** — an [LruCache] keyed on `(sessionId, lastOutputAt)`. `lastOutputAt` is the server's
|
||||
* last-PTY-output timestamp, so an UNCHANGED `lastOutputAt` means the screen is byte-identical and we
|
||||
* return the cached bitmap WITHOUT re-fetching or re-rendering. Keying goes through the [BitmapCache]
|
||||
* seam so the "unchanged ⇒ no re-render" invariant is testable against a plain-map fake.
|
||||
* - **Concurrency cap** — a fair (FIFO) [Semaphore] with 2 permits bounds the fetch+render fan-out so a
|
||||
* grid of many cards never spawns dozens of concurrent emulators / large-bitmap allocations.
|
||||
* - **In-flight dedup** — a `Map<Key, Deferred<Bitmap>>` under a [Mutex]: a second request for a key
|
||||
* already rendering AWAITS the first's [Deferred] instead of starting a duplicate fetch/render.
|
||||
* - **Failure fallback** — any fetch/render failure caches the rasterizer's PLACEHOLDER bitmap under the
|
||||
* same key, so a broken/oversized session is not retried in a hot scroll loop.
|
||||
*
|
||||
* The render coroutine runs in this pipeline's own [scope] (not the caller's), so a caller that scrolls
|
||||
* a card off-screen and cancels its collect never cancels a shared render other cards still await.
|
||||
*/
|
||||
public class ThumbnailPipeline(
|
||||
private val previewSource: PreviewSource,
|
||||
private val rasterizer: ThumbnailRasterizer,
|
||||
private val cache: BitmapCache = LruBitmapCache(DEFAULT_CACHE_BYTES),
|
||||
dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Default,
|
||||
maxConcurrent: Int = MAX_CONCURRENT_RENDERS,
|
||||
) {
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
/** Fair (FIFO) permit gate around the fetch+render of ONE thumbnail (plan §6.7). */
|
||||
private val renderGate = Semaphore(maxConcurrent)
|
||||
|
||||
/** In-flight renders by key; guarded by [inFlightMutex]. A second same-key request shares the entry. */
|
||||
private val inFlight = HashMap<ThumbnailKey, Deferred<Bitmap>>()
|
||||
private val inFlightMutex = Mutex()
|
||||
|
||||
/**
|
||||
* The cached (or freshly rendered) thumbnail for [session]. Returns the cached bitmap immediately when
|
||||
* `(id, lastOutputAt)` is unchanged; otherwise fetches the preview over mTLS, rasterises off-screen,
|
||||
* caches, and returns it. Never throws for a fetch/render failure — a placeholder is cached and
|
||||
* returned instead (only cooperative cancellation of [scope] propagates).
|
||||
*/
|
||||
public suspend fun thumbnail(session: LiveSessionInfo): Bitmap {
|
||||
val key = ThumbnailKey(session.id, session.lastOutputAt)
|
||||
// Fast path: an unchanged lastOutputAt ⇒ identical screen ⇒ never re-render (§6.7).
|
||||
cache.get(key)?.let { return it }
|
||||
|
||||
val deferred = inFlightMutex.withLock {
|
||||
// Re-check under the lock: a render that finished between the fast path and here has already
|
||||
// populated the cache and removed its in-flight entry.
|
||||
cache.get(key)?.let { return it }
|
||||
inFlight[key] ?: startRender(key).also { inFlight[key] = it }
|
||||
}
|
||||
return deferred.await()
|
||||
}
|
||||
|
||||
/** Cancel all in-flight renders and release the pipeline scope (call from the owner's teardown). */
|
||||
public fun close() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun startRender(key: ThumbnailKey): Deferred<Bitmap> = scope.async {
|
||||
val bitmap = renderGuarded(key.sessionId)
|
||||
// Publish under the lock so a concurrent [thumbnail] re-check sees the result the instant this
|
||||
// key stops being in-flight — success AND placeholder are cached identically (§6.7 no-retry-storm).
|
||||
inFlightMutex.withLock {
|
||||
cache.put(key, bitmap)
|
||||
inFlight.remove(key)
|
||||
}
|
||||
bitmap
|
||||
}
|
||||
|
||||
private suspend fun renderGuarded(sessionId: UUID): Bitmap =
|
||||
try {
|
||||
renderGate.withPermit {
|
||||
val preview = previewSource.fetch(sessionId)
|
||||
rasterizer.rasterize(preview)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e // never swallow cooperative cancellation
|
||||
} catch (t: Throwable) {
|
||||
// Any fetch/render failure (network, oversized body, malformed escape) → cache a placeholder
|
||||
// so the broken session isn't retried on every scroll frame (§6.7).
|
||||
rasterizer.placeholder()
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/** Plan §6.7: the render/fetch fan-out is capped at 2 concurrent renders. */
|
||||
public const val MAX_CONCURRENT_RENDERS: Int = 2
|
||||
|
||||
/** Default LRU budget for cached thumbnails (bytes). Small — thumbnails are transient UI cache. */
|
||||
public const val DEFAULT_CACHE_BYTES: Int = 8 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key: an unchanged `(sessionId, lastOutputAt)` pair means the server's last PTY output is
|
||||
* unchanged ⇒ the preview is byte-identical ⇒ the cached bitmap is reused verbatim (plan §6.7). A `null`
|
||||
* [lastOutputAt] (pre-P1 server that omits it) is a stable key too, so such a session renders once and is
|
||||
* then served from cache until its id changes.
|
||||
*/
|
||||
public data class ThumbnailKey(val sessionId: UUID, val lastOutputAt: Long?)
|
||||
|
||||
/** Fetches the read-only preview bytes for a session (plan §6.7: over the shared mTLS OkHttp client). */
|
||||
public fun interface PreviewSource {
|
||||
public suspend fun fetch(sessionId: UUID): SessionPreview
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterises a [SessionPreview] into a `Bitmap` (the off-screen-emulator → `Canvas` cell painter). The
|
||||
* seam boundary: this is the ONLY android.graphics-touching surface, kept behind an interface so the
|
||||
* pipeline's cache/concurrency/dedup policy is JVM-unit-testable and the pixel raster is device-verified.
|
||||
*/
|
||||
public interface ThumbnailRasterizer {
|
||||
/** Off-screen emulator append + per-cell `Canvas` draw. */
|
||||
public fun rasterize(preview: SessionPreview): Bitmap
|
||||
|
||||
/** A cheap solid bitmap cached on any fetch/render failure (no retry storm). */
|
||||
public fun placeholder(): Bitmap
|
||||
}
|
||||
|
||||
/** The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable. */
|
||||
public interface BitmapCache {
|
||||
public fun get(key: ThumbnailKey): Bitmap?
|
||||
public fun put(key: ThumbnailKey, bitmap: Bitmap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Production [BitmapCache] backed by an [LruCache] sized by each bitmap's allocation byte count (plan
|
||||
* §6.7). Instantiated only in production (the pipeline's default arg); unit tests inject a map-backed fake
|
||||
* so `android.util.LruCache` — a stub off-device — is never touched under JVM test.
|
||||
*/
|
||||
public class LruBitmapCache(maxBytes: Int) : BitmapCache {
|
||||
private val lru = object : LruCache<ThumbnailKey, Bitmap>(maxBytes) {
|
||||
override fun sizeOf(key: ThumbnailKey, value: Bitmap): Int = value.allocationByteCount.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
override fun get(key: ThumbnailKey): Bitmap? = lru.get(key)
|
||||
override fun put(key: ThumbnailKey, bitmap: Bitmap) {
|
||||
lru.put(key, bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production [PreviewSource] over an [ApiClient] (which sends through the shared mTLS `HttpTransport`, so
|
||||
* tunnel-host previews traverse nginx). Re-caps the body at 256 KiB client-side — defence-in-depth over
|
||||
* the server's own cap (plan §6.7 / §4.2).
|
||||
*/
|
||||
public class ApiPreviewSource(private val apiClient: ApiClient) : PreviewSource {
|
||||
override suspend fun fetch(sessionId: UUID): SessionPreview =
|
||||
PreviewCap.cap(apiClient.preview(sessionId))
|
||||
}
|
||||
|
||||
/** Client-side preview-size guard (plan §6.7: cap data at 256 KiB). Pure — JVM-testable. */
|
||||
public object PreviewCap {
|
||||
/** 256 KiB — mirrors the server's `GET /live-sessions/:id/preview` data cap. */
|
||||
public const val MAX_PREVIEW_BYTES: Int = 256 * 1024
|
||||
|
||||
/**
|
||||
* Cap [preview]'s UTF-8 byte length at [MAX_PREVIEW_BYTES], keeping the TAIL (the most recent screen)
|
||||
* so a rogue/huge body can't drive an unbounded emulator append. A tail cut on a UTF-8 boundary at
|
||||
* worst mangles one leading glyph — harmless for a thumbnail.
|
||||
*/
|
||||
public fun cap(preview: SessionPreview): SessionPreview {
|
||||
val bytes = preview.data.toByteArray(Charsets.UTF_8)
|
||||
if (bytes.size <= MAX_PREVIEW_BYTES) return preview
|
||||
val tail = bytes.copyOfRange(bytes.size - MAX_PREVIEW_BYTES, bytes.size)
|
||||
return preview.copy(data = String(tail, Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">WebTerm</string>
|
||||
</resources>
|
||||
25
android/app/src/main/res/values/themes.xml
Normal file
25
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Window chrome only (dark-first) — the actual UI colors come from Compose's
|
||||
WebTermTheme. Parent is the PLATFORM Material theme so no Material
|
||||
Components (com.google.android.material) XML dependency is pulled in;
|
||||
WebTerm is a pure-Compose app. The dark windowBackground avoids a white
|
||||
flash before the first Compose frame. -->
|
||||
<style name="Theme.WebTerm" parent="@android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">#FF14130F</item>
|
||||
<item name="android:statusBarColor">#FF14130F</item>
|
||||
<item name="android:navigationBarColor">#FF14130F</item>
|
||||
</style>
|
||||
|
||||
<!-- The Allow-action trampoline (A30, push/AllowTrampolineActivity): a fully transparent,
|
||||
no-title, no-animation window that hosts ONLY the system BiometricPrompt sheet. Paired with
|
||||
android:excludeFromRecents in the manifest so it never leaves a recents card. -->
|
||||
<style name="Theme.WebTerm.Translucent" parent="@android:style/Theme.Material.Dialog.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowIsFloating">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
<item name="android:windowAnimationStyle">@null</item>
|
||||
</style>
|
||||
</resources>
|
||||
10
android/app/src/main/res/xml/network_security_config.xml
Normal file
10
android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- STUB (plan §8 / A19). Cleartext is OFF globally. A19 will add
|
||||
<domain-config cleartextTrafficPermitted="true"> entries for the private-LAN
|
||||
and Tailscale CIDRs that legitimately need ws:// (10/8, 172.16/12,
|
||||
192.168/16, 100.64/10). Tunnel (*.terminal.yaojia.wang) and Tailscale
|
||||
MagicDNS hosts present real LE certs and use default system trust (§6.9),
|
||||
so they stay under this base-config. -->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,25 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.session.AwayDigest
|
||||
|
||||
/**
|
||||
* [AwayDigestView] pure summary logic (A22): the one-line count phrase. The card layout / expand
|
||||
* affordance and the all-zero "render nothing" branch are device-QA (the VM already holds `null` for
|
||||
* an empty digest, [wang.yaojia.webterm.viewmodels.GateViewModelTest]).
|
||||
*/
|
||||
class AwayDigestViewTest {
|
||||
|
||||
@Test
|
||||
fun `counts and flags join into the away summary`() {
|
||||
val digest = AwayDigest(toolRuns = 3, waitingCount = 1, sawDone = true, sawStuck = false, recent = emptyList())
|
||||
assertEquals("离开期间:3 次工具调用 · 1 次等待 · 已完成", summaryLine(digest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero-count parts are omitted`() {
|
||||
val digest = AwayDigest(toolRuns = 0, waitingCount = 2, sawDone = false, sawStuck = true, recent = emptyList())
|
||||
assertEquals("离开期间:2 次等待 · 疑似卡住", summaryLine(digest))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The [continueLastModel] derivation (plan §5 A29 Verify): the banner is shown IFF a last session exists.
|
||||
* A non-blank persisted id yields a [ContinueLastModel]; `null`/blank yields `null` (banner hidden). The
|
||||
* composable's "render nothing on null" branch + layout are device-QA (plan §7); this is the JVM core.
|
||||
*/
|
||||
class ContinueLastBannerTest {
|
||||
|
||||
@Test
|
||||
fun `a persisted last session id yields a shown banner model`() {
|
||||
assertEquals(ContinueLastModel("sess-9"), continueLastModel("sess-9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no last session id yields no banner`() {
|
||||
assertNull(continueLastModel(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank id is treated as no last session`() {
|
||||
assertNull(continueLastModel(" "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import android.view.KeyEvent
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.session.KeyByteMap
|
||||
|
||||
/**
|
||||
* A17 core-contract guard (pure JVM — no device). Two things are verified here; the
|
||||
* Compose layout, IME-inset behaviour and real key events are device-QA (plan §7):
|
||||
*
|
||||
* 1. **Key-bar byte contract** — every button funnels its key through [KeyByteMap]
|
||||
* to a fake send sink and produces the EXACT web-parity byte string.
|
||||
* 2. **DECCKM split** — [HardwareKeyRouter.resolve] routes Esc / Ctrl-chord / ⇧Tab
|
||||
* app-side, and LEAVES arrows / Enter / plain Tab to Termux's `KeyHandler`.
|
||||
*
|
||||
* [EXPECTED_BYTES] is transcribed INDEPENDENTLY from public/keybar.ts `KEY_MAP`
|
||||
* (ESC rebuilt from `Char(0x1B)` = web `\x1b`), so any drift in [KeyByteMap] or the
|
||||
* [KEYBAR_LAYOUT] wiring fails here — not silently on a device.
|
||||
*/
|
||||
class KeyBarTest {
|
||||
|
||||
private val esc = Char(0x1B).toString()
|
||||
|
||||
/** KeyByteMap.Key → byte string, transcribed straight from keybar.ts `KEY_MAP`. */
|
||||
private val EXPECTED_BYTES: Map<KeyByteMap.Key, String> = mapOf(
|
||||
KeyByteMap.Key.ESC to esc, // '\x1b'
|
||||
KeyByteMap.Key.ESC_ESC to esc + esc, // '\x1b\x1b'
|
||||
KeyByteMap.Key.SHIFT_TAB to esc + "[Z", // '\x1b[Z'
|
||||
KeyByteMap.Key.ARROW_UP to esc + "[A", // '\x1b[A'
|
||||
KeyByteMap.Key.ARROW_DOWN to esc + "[B", // '\x1b[B'
|
||||
KeyByteMap.Key.ARROW_LEFT to esc + "[D", // '\x1b[D'
|
||||
KeyByteMap.Key.ARROW_RIGHT to esc + "[C", // '\x1b[C'
|
||||
KeyByteMap.Key.ENTER to "\r", // '\r' — NOT '\n'
|
||||
KeyByteMap.Key.CTRL_C to Char(0x03).toString(), // '\x03'
|
||||
KeyByteMap.Key.CTRL_R to Char(0x12).toString(), // '\x12'
|
||||
KeyByteMap.Key.CTRL_O to Char(0x0F).toString(), // '\x0f'
|
||||
KeyByteMap.Key.CTRL_L to Char(0x0C).toString(), // '\x0c'
|
||||
KeyByteMap.Key.CTRL_T to Char(0x14).toString(), // '\x14'
|
||||
KeyByteMap.Key.CTRL_B to Char(0x02).toString(), // '\x02'
|
||||
KeyByteMap.Key.CTRL_D to Char(0x04).toString(), // '\x04'
|
||||
KeyByteMap.Key.TAB to "\t", // '\t'
|
||||
KeyByteMap.Key.SLASH to "/", // '/'
|
||||
)
|
||||
|
||||
// ── 1. Key-bar byte contract ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `every key-bar button emits its exact web-parity bytes through the sink`() {
|
||||
for (button in KEYBAR_LAYOUT) {
|
||||
// Arrange: a fake IME-bypass sink capturing every send.
|
||||
val captured = mutableListOf<String>()
|
||||
|
||||
// Act: tap the button (exactly what onClick does).
|
||||
emitKey(button.key, captured::add)
|
||||
|
||||
// Assert: one send, byte-for-byte the expected sequence.
|
||||
val expected = EXPECTED_BYTES[button.key] ?: error("no expected bytes for ${button.key}")
|
||||
assertEquals(1, captured.size, "one send per tap for ${button.label}")
|
||||
assertEquals(expected, captured.single(), "byte mismatch for ${button.label}")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `layout order, glyphs and keys mirror the web key-bar`() {
|
||||
val expectedKeys = listOf(
|
||||
KeyByteMap.Key.ESC, KeyByteMap.Key.ESC_ESC, KeyByteMap.Key.SHIFT_TAB,
|
||||
KeyByteMap.Key.ARROW_UP, KeyByteMap.Key.ARROW_DOWN, KeyByteMap.Key.ENTER,
|
||||
KeyByteMap.Key.CTRL_C, KeyByteMap.Key.CTRL_R, KeyByteMap.Key.CTRL_O,
|
||||
KeyByteMap.Key.CTRL_L, KeyByteMap.Key.CTRL_T, KeyByteMap.Key.CTRL_B,
|
||||
KeyByteMap.Key.CTRL_D, KeyByteMap.Key.TAB, KeyByteMap.Key.ARROW_LEFT,
|
||||
KeyByteMap.Key.ARROW_RIGHT, KeyByteMap.Key.SLASH,
|
||||
)
|
||||
assertEquals(expectedKeys, KEYBAR_LAYOUT.map { it.key })
|
||||
assertEquals("Esc", KEYBAR_LAYOUT.first().label)
|
||||
assertTrue(KEYBAR_LAYOUT.first().isPrimary, "Esc is the primary (interrupt) key")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enter emits carriage-return not linefeed`() {
|
||||
val captured = mutableListOf<String>()
|
||||
emitKey(KeyByteMap.Key.ENTER, captured::add)
|
||||
assertEquals("\r", captured.single())
|
||||
}
|
||||
|
||||
// ── 2. DECCKM split routing (plan §6.3 source #3) ────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `esc is app-handled with the ESC byte`() {
|
||||
val result = HardwareKeyRouter.resolve(KeyEvent.KEYCODE_ESCAPE, ctrl = false, shift = false, alt = false)
|
||||
assertEquals(HardwareKeyResult.AppHandled(esc), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `arrows are left to Termux KeyHandler so DECCKM stays correct`() {
|
||||
val arrows = listOf(
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
)
|
||||
for (code in arrows) {
|
||||
assertEquals(
|
||||
HardwareKeyResult.DeferToTerminal,
|
||||
HardwareKeyRouter.resolve(code, ctrl = false, shift = false, alt = false),
|
||||
"arrow keyCode=$code must defer (DECCKM emits ESC O A, never a hardcoded ESC [ A)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enter and plain Tab are left to Termux KeyHandler`() {
|
||||
assertEquals(
|
||||
HardwareKeyResult.DeferToTerminal,
|
||||
HardwareKeyRouter.resolve(KeyEvent.KEYCODE_ENTER, ctrl = false, shift = false, alt = false),
|
||||
)
|
||||
assertEquals(
|
||||
HardwareKeyResult.DeferToTerminal,
|
||||
HardwareKeyRouter.resolve(KeyEvent.KEYCODE_TAB, ctrl = false, shift = false, alt = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift+Tab is app-handled with the reverse-tab sequence`() {
|
||||
val result = HardwareKeyRouter.resolve(KeyEvent.KEYCODE_TAB, ctrl = false, shift = true, alt = false)
|
||||
assertEquals(HardwareKeyResult.AppHandled(esc + "[Z"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each mapped Ctrl-letter chord is app-handled with its control byte`() {
|
||||
val expected = mapOf(
|
||||
KeyEvent.KEYCODE_C to KeyByteMap.Key.CTRL_C,
|
||||
KeyEvent.KEYCODE_R to KeyByteMap.Key.CTRL_R,
|
||||
KeyEvent.KEYCODE_O to KeyByteMap.Key.CTRL_O,
|
||||
KeyEvent.KEYCODE_L to KeyByteMap.Key.CTRL_L,
|
||||
KeyEvent.KEYCODE_T to KeyByteMap.Key.CTRL_T,
|
||||
KeyEvent.KEYCODE_B to KeyByteMap.Key.CTRL_B,
|
||||
KeyEvent.KEYCODE_D to KeyByteMap.Key.CTRL_D,
|
||||
)
|
||||
for ((code, key) in expected) {
|
||||
assertEquals(
|
||||
HardwareKeyResult.AppHandled(KeyByteMap.bytes(key)),
|
||||
HardwareKeyRouter.resolve(code, ctrl = true, shift = false, alt = false),
|
||||
"Ctrl+ keyCode=$code should map to $key",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmapped Ctrl-letter defers to Termux`() {
|
||||
// Ctrl+A is not one of the web key-bar chords → Termux handles it.
|
||||
assertEquals(
|
||||
HardwareKeyResult.DeferToTerminal,
|
||||
HardwareKeyRouter.resolve(KeyEvent.KEYCODE_A, ctrl = true, shift = false, alt = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain letter is left to Termux (typed via IME, not a chord)`() {
|
||||
assertEquals(
|
||||
HardwareKeyResult.DeferToTerminal,
|
||||
HardwareKeyRouter.resolve(KeyEvent.KEYCODE_C, ctrl = false, shift = false, alt = false),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Visibility heuristic (web-parity mobile-only + hardware-keyboard auto-hide) ─
|
||||
|
||||
@Test
|
||||
fun `key-bar shows on a narrow phone with no hardware keyboard`() {
|
||||
assertTrue(shouldShowKeyBar(screenWidthDp = 360, hardwareKeyboardPresent = false))
|
||||
assertTrue(shouldShowKeyBar(screenWidthDp = WIDE_SCREEN_MAX_DP, hardwareKeyboardPresent = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `key-bar hides on a wide screen or when a hardware keyboard is present`() {
|
||||
assertFalse(shouldShowKeyBar(screenWidthDp = 900, hardwareKeyboardPresent = false))
|
||||
assertFalse(shouldShowKeyBar(screenWidthDp = 360, hardwareKeyboardPresent = true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A25 core-contract guard (pure JVM — no device). The Compose chip layout, the
|
||||
* float animation and real taps are device-QA (plan §7); what is load-bearing and
|
||||
* verified here:
|
||||
*
|
||||
* 1. **Palette CRUD** — [QuickReplyStore] add / edit / remove / reorder each return
|
||||
* a NEW list (immutable), with sensible defaults, blank-input guards and a
|
||||
* persistence round-trip through a fake `DataStore<Preferences>`.
|
||||
* 2. **Codec** — [QuickReplyCodec] round-trips (incl. quotes / backslash / newline)
|
||||
* and is defensive against a corrupt blob at rest.
|
||||
* 3. **Tap → exact bytes** — [sendQuickReply] emits the chip text VERBATIM;
|
||||
* [shouldShowQuickReply] floats the row only while a gate is held with chips.
|
||||
*/
|
||||
class QuickReplyTest {
|
||||
|
||||
// ── An in-memory DataStore<Preferences> double (no Android Context needed) ─────
|
||||
private class FakePreferencesDataStore(
|
||||
initial: Preferences = emptyPreferences(),
|
||||
) : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow(initial)
|
||||
override val data: Flow<Preferences> = state
|
||||
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||
val updated = transform(state.value)
|
||||
state.value = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
|
||||
private var idSeq = 0
|
||||
private fun sequentialId(): () -> String = { "id-${idSeq++}" }
|
||||
|
||||
// ── 1. Defaults ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `first read yields the sensible defaults`() = runTest {
|
||||
val store = DataStoreQuickReplyStore(FakePreferencesDataStore())
|
||||
assertEquals(QuickReplyDefaults.chips, store.loadAll())
|
||||
assertTrue(QuickReplyDefaults.chips.isNotEmpty(), "defaults must not be empty")
|
||||
}
|
||||
|
||||
// ── 1. CRUD returns new lists (immutable) + persistence ───────────────────────
|
||||
|
||||
@Test
|
||||
fun `add appends a chip, persists, and returns a new list`() = runTest {
|
||||
val backing = FakePreferencesDataStore()
|
||||
val store = DataStoreQuickReplyStore(backing, newId = sequentialId())
|
||||
|
||||
val before = store.loadAll()
|
||||
val after = store.add("run tests")
|
||||
|
||||
assertEquals(before.size + 1, after.size)
|
||||
assertEquals(QuickReplyChip("id-0", "run tests"), after.last())
|
||||
// A fresh store over the SAME backing sees the persisted list (round-trip).
|
||||
assertEquals(after, DataStoreQuickReplyStore(backing).loadAll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `add stores text verbatim (surrounding whitespace preserved)`() = runTest {
|
||||
val store = DataStoreQuickReplyStore(FakePreferencesDataStore(), newId = sequentialId())
|
||||
val after = store.add(" spaced ")
|
||||
assertEquals(" spaced ", after.last().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank add is a no-op`() = runTest {
|
||||
val store = DataStoreQuickReplyStore(FakePreferencesDataStore())
|
||||
val before = store.loadAll()
|
||||
assertEquals(before, store.add(" "))
|
||||
assertEquals(before, store.add(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit replaces text in place by id, leaving others untouched`() = runTest {
|
||||
val store = DataStoreQuickReplyStore(FakePreferencesDataStore())
|
||||
val after = store.edit("qr-yes", "yes please")
|
||||
|
||||
val edited = after.single { it.id == "qr-yes" }
|
||||
assertEquals("yes please", edited.text)
|
||||
// Position preserved and siblings unchanged.
|
||||
assertEquals(QuickReplyDefaults.chips.map { it.id }, after.map { it.id })
|
||||
assertEquals("continue", after.first { it.id == "qr-continue" }.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit of an unknown id or blank text is a no-op`() = runTest {
|
||||
val store = DataStoreQuickReplyStore(FakePreferencesDataStore())
|
||||
val defaults = store.loadAll()
|
||||
assertEquals(defaults, store.edit("does-not-exist", "x"))
|
||||
assertEquals(defaults, store.edit("qr-yes", " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove drops the chip by id, persists, and unknown id is a no-op`() = runTest {
|
||||
val backing = FakePreferencesDataStore()
|
||||
val store = DataStoreQuickReplyStore(backing)
|
||||
|
||||
val after = store.remove("qr-no")
|
||||
assertFalse(after.any { it.id == "qr-no" })
|
||||
assertEquals(QuickReplyDefaults.chips.size - 1, after.size)
|
||||
assertEquals(after, DataStoreQuickReplyStore(backing).loadAll())
|
||||
|
||||
// Removing again (now unknown) changes nothing.
|
||||
assertEquals(after, store.remove("qr-no"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting every chip persists an empty list and does NOT re-seed defaults`() = runTest {
|
||||
val backing = FakePreferencesDataStore()
|
||||
val store = DataStoreQuickReplyStore(backing)
|
||||
|
||||
var list = store.loadAll()
|
||||
list.forEach { store.remove(it.id) }
|
||||
|
||||
assertTrue(store.loadAll().isEmpty(), "delete-all must persist as empty")
|
||||
assertTrue(DataStoreQuickReplyStore(backing).loadAll().isEmpty(), "empty must not re-seed defaults")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move reorders immutably and out-of-range is a no-op`() = runTest {
|
||||
val store = DataStoreQuickReplyStore(FakePreferencesDataStore())
|
||||
val ids = store.loadAll().map { it.id }
|
||||
|
||||
val moved = store.move(0, ids.lastIndex)
|
||||
val expected = ids.drop(1) + ids.first()
|
||||
assertEquals(expected, moved.map { it.id })
|
||||
|
||||
// Out-of-range indices leave the list untouched.
|
||||
assertEquals(moved, store.move(-1, 0))
|
||||
assertEquals(moved, store.move(0, 99))
|
||||
}
|
||||
|
||||
// ── 1. In-memory double parity ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `in-memory double supports the same CRUD and defaults`() = runTest {
|
||||
val store = InMemoryQuickReplyStore(newId = sequentialId())
|
||||
assertEquals(QuickReplyDefaults.chips, store.loadAll())
|
||||
|
||||
val added = store.add("deploy")
|
||||
assertEquals("deploy", added.last().text)
|
||||
val removed = store.remove(added.last().id)
|
||||
assertFalse(removed.any { it.text == "deploy" })
|
||||
|
||||
// Seeding with an explicit initial list is honoured (no default re-seed).
|
||||
val empty = InMemoryQuickReplyStore(initial = emptyList())
|
||||
assertTrue(empty.loadAll().isEmpty())
|
||||
}
|
||||
|
||||
// ── 2. Codec round-trip + defensiveness ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `codec round-trips including quotes, backslashes and newlines`() = runTest {
|
||||
val chips = listOf(
|
||||
QuickReplyChip("a", "plain"),
|
||||
QuickReplyChip("b", "he said \"hi\""),
|
||||
QuickReplyChip("c", "path\\to\\thing"),
|
||||
QuickReplyChip("d", "line1\nline2\t/slash"),
|
||||
)
|
||||
assertEquals(chips, QuickReplyCodec.decode(QuickReplyCodec.encode(chips)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `codec is defensive against corrupt or malformed blobs`() {
|
||||
assertTrue(QuickReplyCodec.decode(null).isEmpty())
|
||||
assertTrue(QuickReplyCodec.decode("").isEmpty())
|
||||
assertTrue(QuickReplyCodec.decode("not json {").isEmpty())
|
||||
// A non-array top level is rejected wholesale.
|
||||
assertTrue(QuickReplyCodec.decode("""{"id":"a","text":"b"}""").isEmpty())
|
||||
// Chips missing a string id/text are dropped; valid ones survive.
|
||||
val mixed = QuickReplyCodec.decode(
|
||||
"""[{"text":"noid"},{"id":"ok","text":"keep"},{"id":"x"},{"id":5,"text":"num"}]""",
|
||||
)
|
||||
assertEquals(listOf(QuickReplyChip("ok", "keep")), mixed)
|
||||
}
|
||||
|
||||
// ── 3. Tap → exact bytes, and the float-visibility heuristic ───────────────────
|
||||
|
||||
@Test
|
||||
fun `tapping a chip emits its exact text verbatim, once`() {
|
||||
val captured = mutableListOf<String>()
|
||||
sendQuickReply(QuickReplyChip("id", "git status\n"), captured::add)
|
||||
assertEquals(1, captured.size)
|
||||
assertEquals("git status\n", captured.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the row floats only while a gate is held with at least one chip`() {
|
||||
assertTrue(shouldShowQuickReply(gateHeld = true, hasChips = true))
|
||||
assertFalse(shouldShowQuickReply(gateHeld = false, hasChips = true))
|
||||
assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = false))
|
||||
}
|
||||
|
||||
// ── Pure transforms never mutate the receiver ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `pure transforms return fresh lists and never mutate the receiver`() {
|
||||
val original = QuickReplyDefaults.chips
|
||||
|
||||
assertEquals(original.size + 1, original.adding("x", "nid").size)
|
||||
assertEquals("nid", original.adding("x", "nid").last().id)
|
||||
assertEquals(original, original.adding(" ", "nid")) // blank guarded
|
||||
|
||||
assertEquals("edited", original.editing("qr-yes", "edited").single { it.id == "qr-yes" }.text)
|
||||
assertEquals(original.size - 1, original.removingId("qr-yes").size)
|
||||
// move(first → end): the original first chip becomes the last.
|
||||
assertEquals(original.first().id, original.moving(0, original.lastIndex).last().id)
|
||||
|
||||
// The receiver is unchanged after every transform above.
|
||||
assertEquals(QuickReplyDefaults.chips, original)
|
||||
assertNull(original.firstOrNull { it.text == "edited" })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.session.Connection
|
||||
import wang.yaojia.webterm.session.ConnectionState
|
||||
import wang.yaojia.webterm.session.Exited
|
||||
import wang.yaojia.webterm.session.FailureReason
|
||||
import wang.yaojia.webterm.session.Output
|
||||
import wang.yaojia.webterm.wire.WireConstants
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* The [bannerModel] reducer contract (plan §1, §5 A21 Verify). Pure JVM — no device:
|
||||
* 1. **Precedence** — terminal phases (`exited`/`failed`) OUTRANK transient ones (`connecting`/
|
||||
* `reconnecting`): once terminal, a later transient connection event never overwrites the banner.
|
||||
* 2. **Spawn failure** — `exit(-1)` is a spawn failure (reason required), no spinner, offers new session.
|
||||
* 3. **replayTooLarge** — non-retryable: NO spinner + actionable + a "new session" affordance.
|
||||
* 4. **Normal exit** — `exit(code)` (code ≥ 0) is not a spawn failure but still offers a new session.
|
||||
*/
|
||||
class ReconnectBannerTest {
|
||||
|
||||
// ── 1. Precedence: terminal OUTRANKS transient ───────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `exited outranks a later connecting event`() {
|
||||
val exited = bannerModel(BannerModel.Hidden, Exited(code = 0, reason = null))
|
||||
assertTrue(exited is BannerModel.Exited)
|
||||
|
||||
// A stray transient connect arriving after the shell exited must NOT clear the exit banner.
|
||||
val after = bannerModel(exited, Connection(ConnectionState.Connecting))
|
||||
assertEquals(exited, after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed outranks a later reconnecting event`() {
|
||||
val failed = bannerModel(
|
||||
BannerModel.Hidden,
|
||||
Connection(ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE)),
|
||||
)
|
||||
assertTrue(failed is BannerModel.Failed)
|
||||
|
||||
val after = bannerModel(failed, Connection(ConnectionState.Reconnecting(attempt = 3, next = 8.seconds)))
|
||||
assertEquals(failed, after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh exit replaces an earlier failure (terminal replaces terminal)`() {
|
||||
val failed = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE)))
|
||||
val exited = bannerModel(failed, Exited(code = 137, reason = "killed"))
|
||||
assertEquals(BannerModel.Exited(137, "killed"), exited)
|
||||
}
|
||||
|
||||
// ── 2. Spawn-failure copy (code == -1) ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `exit code -1 is a spawn failure and carries the reason`() {
|
||||
val model = bannerModel(BannerModel.Hidden, Exited(code = WireConstants.SPAWN_FAILED_EXIT_CODE, reason = "no shell"))
|
||||
val exited = model as BannerModel.Exited
|
||||
assertEquals(-1, exited.code)
|
||||
assertTrue(exited.isSpawnFailure)
|
||||
assertEquals("no shell", exited.reason)
|
||||
assertFalse(exited.showsSpinner)
|
||||
assertTrue(exited.offersNewSession)
|
||||
}
|
||||
|
||||
// ── 3. replayTooLarge: no spinner, actionable, new-session action ─────────────
|
||||
|
||||
@Test
|
||||
fun `replayTooLarge shows no spinner and offers a new session`() {
|
||||
val model = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE)))
|
||||
val failed = model as BannerModel.Failed
|
||||
assertEquals(FailureReason.REPLAY_TOO_LARGE, failed.reason)
|
||||
assertFalse(failed.showsSpinner) // never a spinner — reconnecting would hit the same wall forever
|
||||
assertTrue(failed.offersNewSession)
|
||||
}
|
||||
|
||||
// ── 4. Normal exit ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a normal exit is not a spawn failure but still offers a new session`() {
|
||||
val exited = bannerModel(BannerModel.Hidden, Exited(code = 0, reason = null)) as BannerModel.Exited
|
||||
assertFalse(exited.isSpawnFailure)
|
||||
assertFalse(exited.showsSpinner)
|
||||
assertTrue(exited.offersNewSession)
|
||||
}
|
||||
|
||||
// ── Transient reductions (no terminal state in play) ─────────────────────────
|
||||
|
||||
@Test
|
||||
fun `connecting then reconnecting then connected walks the transient states`() {
|
||||
var model: BannerModel = BannerModel.Hidden
|
||||
model = bannerModel(model, Connection(ConnectionState.Connecting))
|
||||
assertEquals(BannerModel.Connecting, model)
|
||||
assertTrue(model.showsSpinner)
|
||||
|
||||
model = bannerModel(model, Connection(ConnectionState.Reconnecting(attempt = 1, next = 2.seconds)))
|
||||
assertEquals(BannerModel.Reconnecting(1, 2.seconds), model)
|
||||
assertTrue(model.showsSpinner)
|
||||
|
||||
model = bannerModel(model, Connection(ConnectionState.Connected))
|
||||
assertEquals(BannerModel.Hidden, model) // connected → banner hidden
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-connection events leave the banner unchanged`() {
|
||||
val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting))
|
||||
assertEquals(connecting, bannerModel(connecting, Output("some bytes")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.wire.PrInfo
|
||||
import wang.yaojia.webterm.wire.RateInfo
|
||||
import wang.yaojia.webterm.wire.StatusTelemetry
|
||||
import wang.yaojia.webterm.wire.Tunables
|
||||
|
||||
/**
|
||||
* [TelemetryChips] pure logic (A22): the stale-TTL threshold ([isTelemetryStale]) and the chip
|
||||
* derivation ([telemetryChipModels]) — formatting, warning thresholds, absent-metric skipping. The
|
||||
* Compose row (greying, scroll) is device-QA.
|
||||
*/
|
||||
class TelemetryChipsTest {
|
||||
|
||||
private val ttl = Tunables.TELEMETRY_STALE_TTL_MS
|
||||
|
||||
// ── Staleness threshold ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `fresh telemetry is not stale`() {
|
||||
assertFalse(isTelemetryStale(atMs = 1_000L, nowMs = 1_000L))
|
||||
assertFalse(isTelemetryStale(atMs = 1_000L, nowMs = 1_000L + ttl / 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly at the TTL boundary is not yet stale (older-than, not at)`() {
|
||||
assertFalse(isTelemetryStale(atMs = 1_000L, nowMs = 1_000L + ttl))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one ms past the TTL is stale`() {
|
||||
assertTrue(isTelemetryStale(atMs = 1_000L, nowMs = 1_000L + ttl + 1))
|
||||
}
|
||||
|
||||
// ── Chip derivation ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `absent metrics produce no chips`() {
|
||||
assertEquals(emptyList<TelemetryChipModel>(), telemetryChipModels(StatusTelemetry(at = 0L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `context cost model PR and rate render in order with formatting`() {
|
||||
val models = telemetryChipModels(
|
||||
StatusTelemetry(
|
||||
contextUsedPct = 41.6,
|
||||
costUsd = 0.5,
|
||||
model = "opus-4.8",
|
||||
pr = PrInfo(number = 7, url = "", reviewState = null),
|
||||
rate = RateInfo(fiveHourPct = 12.0),
|
||||
at = 0L,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("ctx 42%", "$0.50", "opus-4.8", "PR #7", "5h 12%"),
|
||||
models.map { it.text },
|
||||
)
|
||||
assertTrue(models.none { it.isWarning }) // all below thresholds
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `near-full context turns the chip amber`() {
|
||||
val models = telemetryChipModels(StatusTelemetry(contextUsedPct = 90.0, at = 0L))
|
||||
assertEquals("ctx 90%", models.single().text)
|
||||
assertTrue(models.single().isWarning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `near-limit rate turns the chip amber`() {
|
||||
val models = telemetryChipModels(StatusTelemetry(rate = RateInfo(fiveHourPct = 95.0), at = 0L))
|
||||
assertTrue(models.single().isWarning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a changes-requested PR turns the chip amber`() {
|
||||
val models = telemetryChipModels(
|
||||
StatusTelemetry(pr = PrInfo(number = 3, url = "", reviewState = "CHANGES_REQUESTED"), at = 0L),
|
||||
)
|
||||
assertEquals("PR #3", models.single().text)
|
||||
assertTrue(models.single().isWarning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank model string is skipped`() {
|
||||
assertEquals(emptyList<TelemetryChipModel>(), telemetryChipModels(StatusTelemetry(model = " ", at = 0L)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package wang.yaojia.webterm.designsystem
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Regression guard for the FROZEN design-system spec (plan §"Design system",
|
||||
* mirrors iOS `DS`). The expected values are transcribed INDEPENDENTLY from the
|
||||
* spec, so any accidental drift in [DesignSpec] fails here. Pure JVM — no device.
|
||||
*/
|
||||
class DesignSpecTest {
|
||||
|
||||
@Test
|
||||
fun `amber-gold accent hex matches the frozen desktop palette`() {
|
||||
// #E3A64A dark (web --accent) / #C9892F light (web --accent-2); gold needs
|
||||
// dark ink #1A1305 on top (web --on-accent).
|
||||
assertEquals(0xFFE3A64AL, DesignSpec.ACCENT_DARK)
|
||||
assertEquals(0xFFC9892FL, DesignSpec.ACCENT_LIGHT)
|
||||
assertEquals(0xFF1A1305L, DesignSpec.ON_ACCENT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `semantic status colors match the frozen web status set`() {
|
||||
assertEquals(0xFF46D07FL, DesignSpec.STATUS_WORKING) // web --green
|
||||
assertEquals(0xFFF5B14CL, DesignSpec.STATUS_WAITING) // web --amber
|
||||
assertEquals(0xFFFF6B6BL, DesignSpec.STATUS_STUCK) // web --red
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal canvas colors are the fixed desktop values`() {
|
||||
assertEquals(0xFF100F0DL, DesignSpec.TERMINAL_BACKGROUND) // web --bg
|
||||
assertEquals(0xFFECE9E3L, DesignSpec.TERMINAL_FOREGROUND) // web --text
|
||||
assertEquals(0xFFE3A64AL, DesignSpec.TERMINAL_CARET) // gold caret == accent
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spacing scale is exactly 2-4-8-12-16-20-24`() {
|
||||
val scale = listOf(
|
||||
DesignSpec.SPACE_XS2,
|
||||
DesignSpec.SPACE_XS4,
|
||||
DesignSpec.SPACE_SM8,
|
||||
DesignSpec.SPACE_MD12,
|
||||
DesignSpec.SPACE_LG16,
|
||||
DesignSpec.SPACE_XL20,
|
||||
DesignSpec.SPACE_XXL24,
|
||||
)
|
||||
assertEquals(listOf(2f, 4f, 8f, 12f, 16f, 20f, 24f), scale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `radius, stroke, opacity and hit-target tokens match the spec`() {
|
||||
assertEquals(8f, DesignSpec.RADIUS_SM8)
|
||||
assertEquals(12f, DesignSpec.RADIUS_MD12)
|
||||
assertEquals(16f, DesignSpec.RADIUS_LG16)
|
||||
assertEquals(999f, DesignSpec.RADIUS_PILL)
|
||||
assertEquals(1f, DesignSpec.STROKE_HAIRLINE)
|
||||
assertEquals(0.45f, DesignSpec.OPACITY_STALE)
|
||||
assertEquals(0.55f, DesignSpec.OPACITY_EXITED)
|
||||
assertEquals(0.72f, DesignSpec.OPACITY_PRESSED)
|
||||
assertEquals(44f, DesignSpec.MIN_HIT_TARGET)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.wiring.ColdStartRoute
|
||||
|
||||
/**
|
||||
* A32 · DeepLinkRouter (pure whitelist parser) + NavGraph route mappers.
|
||||
*
|
||||
* Android port of iOS `DeepLinkRouterTests`: `webterminal://open?host=&join=` (custom scheme) AND the
|
||||
* verified `https://terminal.yaojia.wang/open?…` App Link both parse; every field is v4-UUID
|
||||
* whitelisted; ANY invalid / ambiguous / missing part → [DeepLinkRoute.Ignore] (never partially
|
||||
* applied) + tallied; push-tap payloads share the same router. Plain-string inputs — no Compose, no
|
||||
* device (NavHost composition is device-QA).
|
||||
*/
|
||||
class DeepLinkRouterTest {
|
||||
|
||||
// v4 UUIDs (version nibble = 4, variant nibble = 8/9/a/b) → pass Validation.isValidSessionId.
|
||||
private val validHostId = "11111111-2222-4333-8444-555555555555"
|
||||
private val validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
|
||||
// version nibble = 1: a syntactic UUID that the server's v4-only SESSION_ID_RE rejects — pins
|
||||
// "reuse Validation, never re-regex".
|
||||
private val v1StyleId = "11111111-2222-1333-8444-555555555555"
|
||||
|
||||
// variant nibble = c (a legal v4 requires 8/9/a/b).
|
||||
private val badVariantId = "11111111-2222-4333-c444-555555555555"
|
||||
|
||||
private fun customUrl(host: String = validHostId, join: String = validSessionId): String =
|
||||
"webterminal://open?host=$host&join=$join"
|
||||
|
||||
private fun appLinkUrl(host: String = validHostId, join: String = validSessionId): String =
|
||||
"https://terminal.yaojia.wang/open?host=$host&join=$join"
|
||||
|
||||
// ── Custom scheme: valid paths ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `valid custom-scheme link routes to OpenSession with both ids`() {
|
||||
assertEquals(
|
||||
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
|
||||
DeepLinkRouter.route(customUrl()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid https App Link routes to OpenSession with both ids`() {
|
||||
assertEquals(
|
||||
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
|
||||
DeepLinkRouter.route(appLinkUrl()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uppercase UUIDs are accepted (Validation is case-insensitive)`() {
|
||||
assertEquals(
|
||||
DeepLinkRoute.OpenSession(hostId = validHostId.uppercase(), sessionId = validSessionId.uppercase()),
|
||||
DeepLinkRouter.route(customUrl(validHostId.uppercase(), validSessionId.uppercase())),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown extra query keys are ignored and the link still routes`() {
|
||||
assertEquals(
|
||||
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
|
||||
DeepLinkRouter.route("webterminal://open?host=$validHostId&join=$validSessionId&utm=x&foo=bar"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query key order does not matter`() {
|
||||
assertEquals(
|
||||
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
|
||||
DeepLinkRouter.route("webterminal://open?join=$validSessionId&host=$validHostId"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing-slash action path is accepted`() {
|
||||
assertEquals(
|
||||
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
|
||||
DeepLinkRouter.route("webterminal://open/?host=$validHostId&join=$validSessionId"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `App Link trailing-slash path is accepted`() {
|
||||
assertEquals(
|
||||
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
|
||||
DeepLinkRouter.route("https://terminal.yaojia.wang/open/?host=$validHostId&join=$validSessionId"),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Whitelist rejections (any invalid part → Ignore) ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `wrong scheme is ignored`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("myapp://open?host=$validHostId&join=$validSessionId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wrong custom-scheme action is ignored`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://kill?host=$validHostId&join=$validSessionId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom-scheme with a non-empty path is ignored`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open/extra?host=$validHostId&join=$validSessionId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `App Link on a foreign host is ignored`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("https://evil.example/open?host=$validHostId&join=$validSessionId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `App Link on the wrong path is ignored`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("https://terminal.yaojia.wang/kill?host=$validHostId&join=$validSessionId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `https with the custom-scheme action as host does not leak through`() {
|
||||
// https://open?... parses to host=open — must NOT be mistaken for the custom-scheme action.
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("https://open?host=$validHostId&join=$validSessionId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing host key is ignored`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open?join=$validSessionId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing join key is ignored`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open?host=$validHostId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate host key is ambiguous and ignored (never partial)`() {
|
||||
assertEquals(
|
||||
DeepLinkRoute.Ignore,
|
||||
DeepLinkRouter.route("webterminal://open?host=$validHostId&host=$validHostId&join=$validSessionId"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-v4 UUID in either field is ignored (reuses Validation, never re-regexed)`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(host = v1StyleId)))
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(join = v1StyleId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bad UUID variant nibble is ignored`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(join = badVariantId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a valid host with an invalid session is never partially applied`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(host = validHostId, join = "not-a-uuid")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an invalid host with a valid session is never partially applied`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(host = "not-a-uuid", join = validSessionId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty values, empty query, and garbage are ignored without crashing`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open?host=&join="))
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open"))
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(host = "not-a-uuid")))
|
||||
// Illegal characters make java.net.URI throw — must be caught → Ignore, never a crash.
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open?host=x&join='; DROP TABLE sessions;--"))
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(""))
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("not even a uri"))
|
||||
}
|
||||
|
||||
// ── Push-tap payload (A30 reuses the same router) ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `push payload with a valid sessionId routes to GateSession (extra keys ignored)`() {
|
||||
val payload = mapOf("sessionId" to validSessionId, "cls" to "gate", "token" to "opaque")
|
||||
assertEquals(DeepLinkRoute.GateSession(sessionId = validSessionId), DeepLinkRouter.route(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push payload without sessionId or non-v4 is ignored`() {
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(emptyMap()))
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(mapOf("cls" to "gate")))
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(mapOf("sessionId" to v1StyleId)))
|
||||
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(mapOf("sessionId" to "")))
|
||||
}
|
||||
|
||||
// ── DeepLinkTally: invalid links are counted, valid ones are not ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `tally increments only on Ignore and is immutable`() {
|
||||
val start = DeepLinkTally()
|
||||
val afterIgnore = start.record(DeepLinkRoute.Ignore)
|
||||
assertEquals(1, afterIgnore.ignored)
|
||||
assertEquals(0, start.ignored) // original untouched (immutable reducer)
|
||||
|
||||
val afterOpen = afterIgnore.record(DeepLinkRoute.OpenSession(validHostId, validSessionId))
|
||||
val afterGate = afterOpen.record(DeepLinkRoute.GateSession(validSessionId))
|
||||
assertEquals(1, afterGate.ignored) // valid routes never bump the counter
|
||||
|
||||
val afterSecondIgnore = afterGate.record(DeepLinkRouter.route("webterminal://bad"))
|
||||
assertEquals(2, afterSecondIgnore.ignored)
|
||||
}
|
||||
|
||||
// ── NavGraph pure route mappers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `cold-start route maps to the pairing or sessions destination`() {
|
||||
assertEquals(NavRoutes.PAIRING, NavRoutes.startRouteFor(ColdStartRoute.PAIRING))
|
||||
assertEquals(NavRoutes.SESSIONS, NavRoutes.startRouteFor(ColdStartRoute.SESSIONS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forDeepLink builds the terminal route for an OpenSession`() {
|
||||
assertEquals(
|
||||
"terminal/$validHostId/$validSessionId",
|
||||
NavRoutes.forDeepLink(DeepLinkRoute.OpenSession(validHostId, validSessionId)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forDeepLink builds the gate route for a GateSession`() {
|
||||
assertEquals("gate/$validSessionId", NavRoutes.forDeepLink(DeepLinkRoute.GateSession(validSessionId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forDeepLink returns null for Ignore (caller does nothing)`() {
|
||||
assertNull(NavRoutes.forDeepLink(DeepLinkRoute.Ignore))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal route pattern args line up with the built route`() {
|
||||
// The concrete route the mapper builds must fill exactly the pattern's declared segments.
|
||||
assertEquals("terminal/{hostId}/{sessionId}", NavRoutes.TERMINAL_PATTERN)
|
||||
assertEquals("gate/{sessionId}", NavRoutes.GATE_PATTERN)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A26 · the single adaptive-layout decision + the pointer-menu gate, both pure (plan §5 A26, R13).
|
||||
*
|
||||
* Mirrors iOS `LayoutPolicyTests`: compact → stack, medium/expanded → list-detail; and the
|
||||
* pointer-context-menu gate (list-detail AND `smallestScreenWidthDp >= 600`). Plain enum / Int inputs —
|
||||
* no Compose, no device.
|
||||
*/
|
||||
class LayoutPolicyTest {
|
||||
|
||||
// ── LayoutPolicy.mode: one decision per width class ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `compact width falls back to the single-pane stack`() {
|
||||
assertEquals(LayoutMode.STACK, LayoutPolicy.mode(WindowWidth.COMPACT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `medium width opens the sidebar plus detail split`() {
|
||||
assertEquals(LayoutMode.LIST_DETAIL, LayoutPolicy.mode(WindowWidth.MEDIUM))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expanded width opens the sidebar plus detail split`() {
|
||||
assertEquals(LayoutMode.LIST_DETAIL, LayoutPolicy.mode(WindowWidth.EXPANDED))
|
||||
}
|
||||
|
||||
// ── PointerMenuPolicy.enabled: list-detail AND a real tablet width ───────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `pointer menu is enabled in list-detail on a tablet at exactly the breakpoint`() {
|
||||
assertTrue(PointerMenuPolicy.enabled(LayoutMode.LIST_DETAIL, smallestScreenWidthDp = 600))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pointer menu is enabled in list-detail on a wide tablet`() {
|
||||
assertTrue(PointerMenuPolicy.enabled(LayoutMode.LIST_DETAIL, smallestScreenWidthDp = 840))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pointer menu is disabled just below the tablet breakpoint`() {
|
||||
assertFalse(PointerMenuPolicy.enabled(LayoutMode.LIST_DETAIL, smallestScreenWidthDp = 599))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pointer menu is disabled in the compact stack even on a wide screen`() {
|
||||
assertFalse(PointerMenuPolicy.enabled(LayoutMode.STACK, smallestScreenWidthDp = 600))
|
||||
assertFalse(PointerMenuPolicy.enabled(LayoutMode.STACK, smallestScreenWidthDp = 1000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the tablet breakpoint is the Material 600dp smallest-width`() {
|
||||
assertEquals(600, PointerMenuPolicy.TABLET_MIN_WIDTH_DP)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package wang.yaojia.webterm.nav
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The new pure route mappers added in app-assembly (the "spawn a new session" seam). The cold-start and
|
||||
* deep-link mappers are covered in `DeepLinkRouterTest`; this pins the [NavRoutes.NEW_SESSION_SENTINEL]
|
||||
* round-trip: [NavRoutes.terminalNew] builds a route whose session slot is the sentinel, and
|
||||
* [NavRoutes.attachSessionId] maps that slot back to `null` (`attach(null)`) while passing a real id
|
||||
* through verbatim. Plain-string inputs — no Compose, no device.
|
||||
*/
|
||||
class NavRoutesTest {
|
||||
|
||||
private val hostId = "11111111-2222-4333-8444-555555555555"
|
||||
private val sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
|
||||
@Test
|
||||
fun `terminalNew builds a route carrying the new-session sentinel`() {
|
||||
assertEquals("terminal/$hostId/${NavRoutes.NEW_SESSION_SENTINEL}", NavRoutes.terminalNew(hostId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachSessionId maps the sentinel to null (a fresh spawn)`() {
|
||||
assertNull(NavRoutes.attachSessionId(NavRoutes.NEW_SESSION_SENTINEL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachSessionId passes a real session id through verbatim`() {
|
||||
assertEquals(sessionId, NavRoutes.attachSessionId(sessionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachSessionId maps a null arg to null`() {
|
||||
assertNull(NavRoutes.attachSessionId(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminalNew route and attachSessionId round-trip to a null spawn id`() {
|
||||
// The session slot the new-session route puts on the wire must decode back to "spawn a new one".
|
||||
val sessionArg = NavRoutes.terminalNew(hostId).substringAfterLast('/')
|
||||
assertNull(NavRoutes.attachSessionId(sessionArg))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* A30 JVM contract (plan §5 A30 / §8): the pure `cls`→notification mapping, content minimization,
|
||||
* the decision-POST payload shape via a fake `ApiClient`, and the token-never-logged discipline.
|
||||
* BiometricPrompt / notification rendering / FCM delivery are device QA (§7).
|
||||
*/
|
||||
class PushDecisionTest {
|
||||
|
||||
private val sessionId: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
private val token = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
private val endpoint: HostEndpoint = HostEndpoint.fromBaseUrl("http://192.168.1.10:3000")!!
|
||||
private val decisionUrl = "http://192.168.1.10:3000/hook/decision"
|
||||
|
||||
// ── NotificationBuilder.plan: cls → actions mapping ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `needs-input maps to a high-importance gate with Allow and Deny actions`() {
|
||||
val plan = NotificationBuilder.plan(PushPayload.CLASS_NEEDS_INPUT)!!
|
||||
assertEquals(listOf(NotificationBuilder.Action.ALLOW, NotificationBuilder.Action.DENY), plan.actions)
|
||||
assertTrue(plan.highImportance)
|
||||
assertEquals(NotificationBuilder.CHANNEL_GATE, plan.channelId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `done maps to a default-importance informational card with no actions`() {
|
||||
val plan = NotificationBuilder.plan(PushPayload.CLASS_DONE)!!
|
||||
assertTrue(plan.actions.isEmpty())
|
||||
assertFalse(plan.highImportance)
|
||||
assertEquals(NotificationBuilder.CHANNEL_DONE, plan.channelId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown class produces no notification`() {
|
||||
assertNull(NotificationBuilder.plan("stuck"))
|
||||
assertNull(NotificationBuilder.plan(""))
|
||||
}
|
||||
|
||||
// ── Content minimization: no cwd/command/terminal bytes, no token in visible text ───────────
|
||||
|
||||
@Test
|
||||
fun `visible notification text never leaks the token, cwd, command, or terminal bytes`() {
|
||||
val leaks = listOf(token, "/home/user/secret-project", "rm -rf /", "[0m", sessionId.toString())
|
||||
for (cls in listOf(PushPayload.CLASS_NEEDS_INPUT, PushPayload.CLASS_DONE)) {
|
||||
val plan = NotificationBuilder.plan(cls)!!
|
||||
val visible = plan.title + "\n" + plan.text
|
||||
for (secret in leaks) {
|
||||
assertFalse(visible.contains(secret), "cls=$cls visible text leaked: $secret")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decision-POST payload shape via a fake ApiClient ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `deny posts sessionId+decision+token with the guarded Origin header`() = runTest {
|
||||
val http = FakeHttpTransport()
|
||||
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204)
|
||||
val submitter = singleHostSubmitter(http)
|
||||
|
||||
val accepted = submitter.submit(sessionId, HookDecision.DENY, token)
|
||||
|
||||
assertTrue(accepted)
|
||||
val request = http.recordedRequests.single()
|
||||
assertEquals(HttpMethod.POST, request.method)
|
||||
assertEquals(decisionUrl, request.url)
|
||||
assertEquals(endpoint.originHeader, request.headers["Origin"])
|
||||
val body = String(request.body!!, Charsets.UTF_8)
|
||||
assertTrue(body.contains("\"sessionId\":\"$sessionId\""), body)
|
||||
assertTrue(body.contains("\"decision\":\"deny\""), body)
|
||||
assertTrue(body.contains("\"token\":\"$token\""), body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `allow posts decision=allow with the token`() = runTest {
|
||||
val http = FakeHttpTransport()
|
||||
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204)
|
||||
|
||||
val accepted = singleHostSubmitter(http).submit(sessionId, HookDecision.ALLOW, token)
|
||||
|
||||
assertTrue(accepted)
|
||||
val body = String(http.recordedRequests.single().body!!, Charsets.UTF_8)
|
||||
assertTrue(body.contains("\"decision\":\"allow\""), body)
|
||||
assertTrue(body.contains("\"token\":\"$token\""), body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 403 (stale or used token, idempotency) is swallowed to a non-accept, never a throw`() = runTest {
|
||||
val http = FakeHttpTransport()
|
||||
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 403)
|
||||
|
||||
val accepted = singleHostSubmitter(http).submit(sessionId, HookDecision.ALLOW, token)
|
||||
|
||||
assertFalse(accepted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no paired host means no POST and a non-accept`() = runTest {
|
||||
val http = FakeHttpTransport()
|
||||
val submitter = PushDecisionSubmitter(
|
||||
loadEndpoints = { emptyList() },
|
||||
clientFor = { ApiClient(it, http) },
|
||||
)
|
||||
|
||||
assertFalse(submitter.submit(sessionId, HookDecision.DENY, token))
|
||||
assertTrue(http.recordedRequests.isEmpty())
|
||||
}
|
||||
|
||||
// ── Multi-host: the payload carries no host, so try each until one accepts ───────────────────
|
||||
|
||||
@Test
|
||||
fun `iterates past a rejecting host to the host that issued the token`() = runTest {
|
||||
val other = HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")!!
|
||||
val http = FakeHttpTransport()
|
||||
http.queueSuccess(HttpMethod.POST, "http://10.0.0.5:3000/hook/decision", status = 403) // wrong host
|
||||
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204) // issuing host
|
||||
val submitter = PushDecisionSubmitter(
|
||||
loadEndpoints = { listOf(other, endpoint) },
|
||||
clientFor = { ApiClient(it, http) },
|
||||
)
|
||||
|
||||
assertTrue(submitter.submit(sessionId, HookDecision.ALLOW, token))
|
||||
assertEquals(2, http.recordedRequests.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stops at the first accepting host (no token blasted to later hosts)`() = runTest {
|
||||
val other = HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")!!
|
||||
val http = FakeHttpTransport()
|
||||
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204) // first host accepts
|
||||
val submitter = PushDecisionSubmitter(
|
||||
loadEndpoints = { listOf(endpoint, other) },
|
||||
clientFor = { ApiClient(it, http) },
|
||||
)
|
||||
|
||||
assertTrue(submitter.submit(sessionId, HookDecision.DENY, token))
|
||||
assertEquals(1, http.recordedRequests.size)
|
||||
assertEquals(decisionUrl, http.recordedRequests.single().url) // second host never contacted
|
||||
}
|
||||
|
||||
// ── Token discipline: never handed to the diagnostics/log seam ──────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the diagnostics seam never receives the token across accept, reject, and error paths`() = runTest {
|
||||
val logged = mutableListOf<String>()
|
||||
val http = FakeHttpTransport()
|
||||
// accept, reject, then no-queued-response (error) — exercise every diagnostics branch.
|
||||
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204)
|
||||
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 403)
|
||||
val submitter = PushDecisionSubmitter(
|
||||
loadEndpoints = { listOf(endpoint) },
|
||||
clientFor = { ApiClient(it, http) },
|
||||
diagnostics = { logged.add(it) },
|
||||
)
|
||||
|
||||
submitter.submit(sessionId, HookDecision.ALLOW, token) // accepted
|
||||
submitter.submit(sessionId, HookDecision.DENY, token) // rejected by the only host
|
||||
submitter.submit(sessionId, HookDecision.DENY, token) // transport error (no queued response)
|
||||
|
||||
assertTrue(logged.isNotEmpty())
|
||||
for (line in logged) {
|
||||
assertFalse(line.contains(token), "diagnostics leaked the token: $line")
|
||||
}
|
||||
}
|
||||
|
||||
// ── PushPayload boundary validation ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `parse keeps the gate token and marks it actionable`() {
|
||||
val payload = PushPayload.parse(
|
||||
mapOf(
|
||||
PushPayload.KEY_SESSION_ID to sessionId.toString(),
|
||||
PushPayload.KEY_CLS to PushPayload.CLASS_NEEDS_INPUT,
|
||||
PushPayload.KEY_TOKEN to token,
|
||||
),
|
||||
)!!
|
||||
assertEquals(sessionId, payload.sessionId)
|
||||
assertEquals(token, payload.token)
|
||||
assertTrue(payload.isActionableGate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse of a done signal has no token and is not an actionable gate`() {
|
||||
val payload = PushPayload.parse(
|
||||
mapOf(
|
||||
PushPayload.KEY_SESSION_ID to sessionId.toString(),
|
||||
PushPayload.KEY_CLS to PushPayload.CLASS_DONE,
|
||||
),
|
||||
)!!
|
||||
assertNull(payload.token)
|
||||
assertFalse(payload.isActionableGate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse rejects a missing or non-UUID sessionId and a missing cls`() {
|
||||
assertNull(PushPayload.parse(mapOf(PushPayload.KEY_CLS to PushPayload.CLASS_DONE)))
|
||||
assertNull(
|
||||
PushPayload.parse(
|
||||
mapOf(PushPayload.KEY_SESSION_ID to "not-a-uuid", PushPayload.KEY_CLS to PushPayload.CLASS_DONE),
|
||||
),
|
||||
)
|
||||
assertNull(PushPayload.parse(mapOf(PushPayload.KEY_SESSION_ID to sessionId.toString())))
|
||||
}
|
||||
|
||||
private fun singleHostSubmitter(http: FakeHttpTransport): PushDecisionSubmitter =
|
||||
PushDecisionSubmitter(
|
||||
loadEndpoints = { listOf(endpoint) },
|
||||
clientFor = { ApiClient(it, http) },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package wang.yaojia.webterm.push
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||
|
||||
/**
|
||||
* [PushRegistrar] fan-out + self-heal contract (plan §5 A31, §4.5). Pure JVM under `runTest`: a
|
||||
* recording [HostPushApi] fake stands in for [ApiClient][wang.yaojia.webterm.api.routes.ApiClient]
|
||||
* (one per host via the `apiFor` seam) and an [InMemoryHostStore] is the paired-host set. Asserts:
|
||||
* - every paired host gets exactly one POST carrying the token;
|
||||
* - self-heal — token rotation re-registers all, a new host registers just it, removal DELETEs just it;
|
||||
* - re-registering the same token is idempotent (client re-POSTs, no crash);
|
||||
* - best-effort — one host's failure never blocks the others, and the error sink never sees the token.
|
||||
*/
|
||||
class PushRegistrarTest {
|
||||
|
||||
private sealed interface Call {
|
||||
val hostId: String
|
||||
val token: String
|
||||
|
||||
data class Register(override val hostId: String, override val token: String) : Call
|
||||
data class Unregister(override val hostId: String, override val token: String) : Call
|
||||
}
|
||||
|
||||
/** Records every register/unregister, tagged with the host it was minted for. */
|
||||
private class RecordingApi(
|
||||
private val hostId: String,
|
||||
private val calls: MutableList<Call>,
|
||||
private val failWith: Throwable? = null,
|
||||
) : HostPushApi {
|
||||
override suspend fun register(token: String) {
|
||||
calls.add(Call.Register(hostId, token))
|
||||
failWith?.let { throw it }
|
||||
}
|
||||
|
||||
override suspend fun unregister(token: String) {
|
||||
calls.add(Call.Unregister(hostId, token))
|
||||
failWith?.let { throw it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun host(id: String): Host =
|
||||
Host.create(id = id, name = "host-$id", baseUrl = "http://192.168.1.$id:3000")!!
|
||||
|
||||
private val token = "fMr0ck3t-token_AB-cd"
|
||||
private val rotated = "n3w-r0tat3d_token-XY"
|
||||
|
||||
@Test
|
||||
fun `registers the token to every paired host, once each`() = runTest {
|
||||
val calls = mutableListOf<Call>()
|
||||
val hosts = InMemoryHostStore(listOf(host("10"), host("11"), host("12")))
|
||||
val registrar = PushRegistrar(hosts, apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
|
||||
|
||||
registrar.registerAllHosts(token)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
Call.Register("10", token),
|
||||
Call.Register("11", token),
|
||||
Call.Register("12", token),
|
||||
),
|
||||
calls,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `token rotation re-registers every host with the new token`() = runTest {
|
||||
val calls = mutableListOf<Call>()
|
||||
val hosts = InMemoryHostStore(listOf(host("10"), host("11")))
|
||||
val registrar = PushRegistrar(hosts, apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
|
||||
|
||||
registrar.registerAllHosts(token)
|
||||
calls.clear()
|
||||
registrar.registerAllHosts(rotated) // FcmService.onNewToken → self-heal
|
||||
|
||||
assertEquals(
|
||||
listOf(Call.Register("10", rotated), Call.Register("11", rotated)),
|
||||
calls,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a newly paired host is registered on its own`() = runTest {
|
||||
val calls = mutableListOf<Call>()
|
||||
val hosts = InMemoryHostStore(listOf(host("10")))
|
||||
val registrar = PushRegistrar(hosts, apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
|
||||
|
||||
registrar.registerHost(host("20"), token)
|
||||
|
||||
assertEquals(listOf(Call.Register("20", token)), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a removed host is unregistered (DELETE) on its own`() = runTest {
|
||||
val calls = mutableListOf<Call>()
|
||||
val hosts = InMemoryHostStore(listOf(host("10")))
|
||||
val registrar = PushRegistrar(hosts, apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
|
||||
|
||||
registrar.unregisterHost(host("10"), token)
|
||||
|
||||
assertEquals(listOf(Call.Unregister("10", token)), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-registering the same token is idempotent (re-POSTs, never crashes)`() = runTest {
|
||||
val calls = mutableListOf<Call>()
|
||||
val hosts = InMemoryHostStore(listOf(host("10")))
|
||||
val registrar = PushRegistrar(hosts, apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
|
||||
|
||||
registrar.registerAllHosts(token)
|
||||
registrar.registerAllHosts(token) // app-start again on top of an already-registered token
|
||||
|
||||
assertEquals(
|
||||
listOf(Call.Register("10", token), Call.Register("10", token)),
|
||||
calls,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one host failing does not block the others and never leaks the token to onError`() = runTest {
|
||||
val calls = mutableListOf<Call>()
|
||||
val errors = mutableListOf<Pair<String, Throwable>>()
|
||||
val boom = IllegalStateException("host-11 unreachable")
|
||||
val hosts = InMemoryHostStore(listOf(host("10"), host("11"), host("12")))
|
||||
val registrar = PushRegistrar(
|
||||
hosts,
|
||||
apiFor = { h -> RecordingApi(h.id, calls, failWith = if (h.id == "11") boom else null) },
|
||||
onError = { h, e -> errors.add(h.id to e) },
|
||||
)
|
||||
|
||||
registrar.registerAllHosts(token)
|
||||
|
||||
// All three attempted (best-effort); the middle one failed but 10 and 12 still went out.
|
||||
assertEquals(
|
||||
listOf(
|
||||
Call.Register("10", token),
|
||||
Call.Register("11", token),
|
||||
Call.Register("12", token),
|
||||
),
|
||||
calls,
|
||||
)
|
||||
assertEquals(1, errors.size)
|
||||
assertEquals("11", errors.single().first)
|
||||
assertTrue(errors.single().second === boom)
|
||||
// The error sink is host+throwable only — the token can never be reconstructed from it.
|
||||
assertFalse(errors.single().second.message!!.contains(token))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty host list is a no-op, never crashes`() = runTest {
|
||||
val calls = mutableListOf<Call>()
|
||||
val registrar = PushRegistrar(InMemoryHostStore(), apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
|
||||
|
||||
registrar.registerAllHosts(token)
|
||||
|
||||
assertTrue(calls.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The new-session-in-cwd routing decision (plan §1, §5 A21 Verify): the phone toolbar action and the
|
||||
* exit-/replay-too-large banner action both produce `attach(null, cwd)` — a fresh spawn (sessionId null)
|
||||
* in the current session's directory. Pure JVM — no device.
|
||||
*/
|
||||
class NewSessionInCwdTest {
|
||||
|
||||
@Test
|
||||
fun `requestFor carries the current cwd and always spawns a new session`() {
|
||||
val request = NewSessionInCwd.requestFor("/home/dev/project")
|
||||
assertNull(request.sessionId, "a new session never re-attaches — sessionId must be null")
|
||||
assertEquals("/home/dev/project", request.cwd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestFor passes a null cwd through unchanged`() {
|
||||
val request = NewSessionInCwd.requestFor(null)
|
||||
assertNull(request.sessionId)
|
||||
assertNull(request.cwd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the produced request equals attach null cwd`() {
|
||||
assertEquals(NewSessionRequest(sessionId = null, cwd = "/srv/app"), NewSessionInCwd.requestFor("/srv/app"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.clienttls.CertificateSummary
|
||||
import wang.yaojia.webterm.clienttls.NoClientIdentityException
|
||||
import wang.yaojia.webterm.clienttls.Pkcs12DecodeException
|
||||
import wang.yaojia.webterm.tlsandroid.ClientSslMaterial
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
/**
|
||||
* [ClientCertViewModel] / [toSummaryView] / [classify] (A27) — the JVM-tested device-cert core.
|
||||
*
|
||||
* Covers the presentation of an installed [CertificateSummary] (subject/issuer/expiry formatting +
|
||||
* the EXPIRED warning flag), the security-load-bearing "a bad passphrase can't clobber the prior
|
||||
* identity" rule (import validates before persisting → error surfaced, on-screen cert unchanged), and
|
||||
* the rotate/remove state transitions. The SAF picker / AndroidKeyStore / Compose shell is device-QA
|
||||
* (plan §7); THIS is the pure core. `runTest` + [UnconfinedTestDispatcher] so a launched load/mutation
|
||||
* runs inline; [EmptyCoroutineContext] as the "io" hop keeps everything in virtual time.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ClientCertViewModelTest {
|
||||
|
||||
/** A canned [IdentityRepository]: `installed` is the live summary; imports validate-before-persist. */
|
||||
private class FakeIdentityRepository(initial: CertificateSummary? = null) : IdentityRepository {
|
||||
var installed: CertificateSummary? = initial
|
||||
|
||||
/** What the next import/rotation yields; throwing simulates a rejected passphrase / bad file. */
|
||||
var nextResult: () -> CertificateSummary = { error("no import configured") }
|
||||
val passphrasesSeen = mutableListOf<String>()
|
||||
var removeCount = 0
|
||||
|
||||
override fun sslMaterial(): ClientSslMaterial = error("sslMaterial is not exercised in unit tests")
|
||||
override fun currentSummary(): CertificateSummary? = installed
|
||||
override fun hasInstalledIdentity(): Boolean = installed != null
|
||||
|
||||
override suspend fun importIdentity(p12Bytes: ByteArray, passphrase: String): CertificateSummary {
|
||||
// Validate-before-persist: nextResult() may throw BEFORE `installed` is touched.
|
||||
val result = nextResult()
|
||||
installed = result
|
||||
passphrasesSeen += passphrase
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun rotate(p12Bytes: ByteArray, passphrase: String): CertificateSummary =
|
||||
importIdentity(p12Bytes, passphrase)
|
||||
|
||||
override suspend fun remove() {
|
||||
installed = null
|
||||
removeCount++
|
||||
}
|
||||
}
|
||||
|
||||
private val notExpiredNow = Instant.parse("2026-01-01T00:00:00Z")
|
||||
private val utc = ZoneId.of("UTC")
|
||||
|
||||
private fun summary(
|
||||
subject: String? = "t1-android",
|
||||
issuer: String? = "webterm-device-ca",
|
||||
notAfter: Instant? = Instant.parse("2027-06-15T12:00:00Z"),
|
||||
) = CertificateSummary(subjectCommonName = subject, issuerCommonName = issuer, notAfter = notAfter)
|
||||
|
||||
private fun newVm(repo: IdentityRepository) = ClientCertViewModel(
|
||||
repository = repo,
|
||||
io = EmptyCoroutineContext,
|
||||
zone = utc,
|
||||
now = { notExpiredNow },
|
||||
)
|
||||
|
||||
private val bytes = byteArrayOf(1, 2, 3)
|
||||
|
||||
// ── Pure summary presentation ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a valid summary formats subject, issuer and expiry and is not expired`() {
|
||||
val view = summary().toSummaryView(now = notExpiredNow, zone = utc)
|
||||
|
||||
assertEquals("t1-android", view.subjectCommonName)
|
||||
assertEquals("webterm-device-ca", view.issuerCommonName)
|
||||
assertTrue(view.expiry.contains("2027"), "expiry should be a formatted date carrying the year")
|
||||
assertFalse(view.isExpired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a not-after in the past flags the summary as expired`() {
|
||||
val view = summary(notAfter = Instant.parse("2020-01-01T00:00:00Z")).toSummaryView(notExpiredNow, utc)
|
||||
assertTrue(view.isExpired, "a past not-after must raise the expired warning")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `absent CN or expiry degrade to UNKNOWN and unknown expiry is not expired`() {
|
||||
val view = summary(subject = null, issuer = null, notAfter = null).toSummaryView(notExpiredNow, utc)
|
||||
|
||||
assertEquals(CertSummaryView.UNKNOWN, view.subjectCommonName)
|
||||
assertEquals(CertSummaryView.UNKNOWN, view.issuerCommonName)
|
||||
assertEquals(CertSummaryView.UNKNOWN, view.expiry)
|
||||
assertFalse(view.isExpired, "unknown expiry is treated as not expired (display-only, fail-open)")
|
||||
}
|
||||
|
||||
// ── Load ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `bind loads the installed identity summary`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val repo = FakeIdentityRepository(initial = summary())
|
||||
val vm = newVm(repo)
|
||||
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertEquals(CertPhase.READY, state.phase)
|
||||
assertEquals("t1-android", state.summary?.subjectCommonName)
|
||||
assertEquals("webterm-device-ca", state.summary?.issuerCommonName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bind with no installed identity shows an empty summary`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val repo = FakeIdentityRepository(initial = null)
|
||||
val vm = newVm(repo)
|
||||
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
assertEquals(CertPhase.READY, vm.uiState.value.phase)
|
||||
assertNull(vm.uiState.value.summary)
|
||||
}
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `importing a p12 installs it and shows its summary`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val repo = FakeIdentityRepository(initial = null)
|
||||
repo.nextResult = { summary(subject = "new-device") }
|
||||
val vm = newVm(repo)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.import(bytes, "correct-passphrase")
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertEquals(CertPhase.READY, state.phase)
|
||||
assertEquals("new-device", state.summary?.subjectCommonName)
|
||||
assertNull(state.error)
|
||||
assertEquals(listOf("correct-passphrase"), repo.passphrasesSeen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bad-passphrase import surfaces the error and keeps the prior identity`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prior = summary(subject = "prior-device")
|
||||
val repo = FakeIdentityRepository(initial = prior)
|
||||
repo.nextResult = { throw UnrecoverableKeyException("MAC check failed") }
|
||||
val vm = newVm(repo)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.import(bytes, "wrong-passphrase")
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertEquals(CertError.BAD_PASSPHRASE, state.error, "a rejected passphrase must be surfaced")
|
||||
// The on-screen cert is UNCHANGED — validate-before-persist means the prior cert stays live.
|
||||
assertEquals("prior-device", state.summary?.subjectCommonName)
|
||||
assertSame(prior, repo.installed, "the repository's live identity must be untouched")
|
||||
assertTrue(repo.passphrasesSeen.isEmpty(), "a rejected import never reaches the persist step")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearError dismisses a surfaced error`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val repo = FakeIdentityRepository(initial = summary())
|
||||
repo.nextResult = { throw Pkcs12DecodeException("corrupt") }
|
||||
val vm = newVm(repo)
|
||||
vm.bind(backgroundScope)
|
||||
vm.import(bytes, "pw")
|
||||
assertEquals(CertError.INVALID_FILE, vm.uiState.value.error)
|
||||
|
||||
vm.clearError()
|
||||
|
||||
assertNull(vm.uiState.value.error)
|
||||
}
|
||||
|
||||
// ── Rotate ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `rotating replaces the installed identity with the new summary`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val repo = FakeIdentityRepository(initial = summary(subject = "old-device"))
|
||||
repo.nextResult = { summary(subject = "rotated-device") }
|
||||
val vm = newVm(repo)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.rotate(bytes, "pw")
|
||||
|
||||
assertEquals("rotated-device", vm.uiState.value.summary?.subjectCommonName)
|
||||
assertNull(vm.uiState.value.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed rotation keeps the prior identity on screen`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val prior = summary(subject = "old-device")
|
||||
val repo = FakeIdentityRepository(initial = prior)
|
||||
repo.nextResult = { throw UnrecoverableKeyException("bad") }
|
||||
val vm = newVm(repo)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.rotate(bytes, "pw")
|
||||
|
||||
assertEquals("old-device", vm.uiState.value.summary?.subjectCommonName)
|
||||
assertEquals(CertError.BAD_PASSPHRASE, vm.uiState.value.error)
|
||||
}
|
||||
|
||||
// ── Remove (confirm-gated) ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `requestRemove then confirm clears the identity`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val repo = FakeIdentityRepository(initial = summary())
|
||||
val vm = newVm(repo)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.requestRemove()
|
||||
assertTrue(vm.uiState.value.confirmingRemove, "requestRemove raises the confirm dialog")
|
||||
|
||||
vm.confirmRemove()
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertFalse(state.confirmingRemove)
|
||||
assertNull(state.summary, "the identity is gone after a confirmed remove")
|
||||
assertEquals(1, repo.removeCount)
|
||||
assertNull(repo.installed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelRemove dismisses the dialog without removing`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val repo = FakeIdentityRepository(initial = summary())
|
||||
val vm = newVm(repo)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.requestRemove()
|
||||
vm.cancelRemove()
|
||||
|
||||
assertFalse(vm.uiState.value.confirmingRemove)
|
||||
assertEquals(0, repo.removeCount, "cancelling must not remove the identity")
|
||||
assertEquals("t1-android", vm.uiState.value.summary?.subjectCommonName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestRemove is a no-op when nothing is installed`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val repo = FakeIdentityRepository(initial = null)
|
||||
val vm = newVm(repo)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.requestRemove()
|
||||
|
||||
assertFalse(vm.uiState.value.confirmingRemove)
|
||||
}
|
||||
|
||||
// ── Error classification (pure) ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `error classification maps each throwable to its coarse CertError`() {
|
||||
assertEquals(CertError.BAD_PASSPHRASE, classify(UnrecoverableKeyException("x")))
|
||||
assertEquals(CertError.INVALID_FILE, classify(Pkcs12DecodeException("x")))
|
||||
assertEquals(CertError.NO_IDENTITY, classify(NoClientIdentityException("x")))
|
||||
assertEquals(CertError.IMPORT_FAILED, classify(IllegalStateException("x")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
|
||||
* flows through the fetcher, files→hunks→lines flatten in order, and the untrusted body decodes
|
||||
* lossily (a malformed file/hunk/line is dropped, its siblings survive). Compose render → device QA.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DiffViewModelTest {
|
||||
|
||||
// ── DiffLineKind.fromWire ───────────────────────────────────────────────────────────────────
|
||||
@Test
|
||||
fun `fromWire maps known kinds and degrades an unknown kind to CONTEXT`() {
|
||||
assertEquals(DiffLineKind.ADDED, DiffLineKind.fromWire("added"))
|
||||
assertEquals(DiffLineKind.REMOVED, DiffLineKind.fromWire("removed"))
|
||||
assertEquals(DiffLineKind.CONTEXT, DiffLineKind.fromWire("context"))
|
||||
assertEquals(DiffLineKind.META, DiffLineKind.fromWire("meta"))
|
||||
assertEquals(DiffLineKind.HUNK, DiffLineKind.fromWire("hunk"))
|
||||
// A future/unknown wire kind must degrade, never crash (mirror of the web default).
|
||||
assertEquals(DiffLineKind.CONTEXT, DiffLineKind.fromWire("future-kind"))
|
||||
}
|
||||
|
||||
// ── flattenDiff ordering ────────────────────────────────────────────────────────────────────
|
||||
@Test
|
||||
fun `flattenDiff emits file then hunk then lines in order, and a single binary row for a binary file`() {
|
||||
val text = DiffFile(
|
||||
oldPath = "a.kt", newPath = "a.kt", status = "modified", added = 1, removed = 1, binary = false,
|
||||
hunks = listOf(
|
||||
DiffHunk("@@ -1 +1 @@", listOf(
|
||||
DiffLine(DiffLineKind.REMOVED, "old"),
|
||||
DiffLine(DiffLineKind.ADDED, "new"),
|
||||
)),
|
||||
),
|
||||
)
|
||||
val bin = DiffFile("img.png", "img.png", "modified", 0, 0, binary = true, hunks = emptyList())
|
||||
val rows = flattenDiff(DiffResult(listOf(text, bin), staged = false, truncated = false))
|
||||
|
||||
assertEquals(6, rows.size)
|
||||
assertTrue(rows[0] is DiffFileHeaderRow)
|
||||
assertTrue(rows[1] is DiffHunkHeaderRow)
|
||||
assertEquals(DiffLineKind.REMOVED, (rows[2] as DiffLineRow).kind)
|
||||
assertEquals(DiffLineKind.ADDED, (rows[3] as DiffLineRow).kind)
|
||||
assertTrue(rows[4] is DiffFileHeaderRow) // binary file's header
|
||||
assertTrue(rows[5] is DiffBinaryRow) // then exactly one binary marker (no hunks)
|
||||
// Ids are stable + monotonic (LazyColumn keys).
|
||||
assertEquals(listOf(0L, 1L, 2L, 3L, 4L, 5L), rows.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a renamed file shows old to new in its header path`() {
|
||||
val renamed = DiffFile("old.kt", "new.kt", status = "renamed", added = 0, removed = 0, binary = false, hunks = emptyList())
|
||||
val header = flattenDiff(DiffResult(listOf(renamed), false, false)).first() as DiffFileHeaderRow
|
||||
assertEquals("old.kt → new.kt", header.path)
|
||||
}
|
||||
|
||||
// ── decodeDiffResult lossy decode ───────────────────────────────────────────────────────────
|
||||
@Test
|
||||
fun `decode drops a file with no newPath and a hunk with no header, keeping the rest`() {
|
||||
val json = """
|
||||
{ "staged": true, "truncated": true, "files": [
|
||||
{ "status": "modified" },
|
||||
{ "newPath": "keep.kt", "added": 2, "removed": 1, "hunks": [
|
||||
{ "lines": [ { "kind": "added", "text": "x" } ] },
|
||||
{ "header": "@@ -1 +1 @@", "lines": [
|
||||
{ "kind": "added", "text": "ok" },
|
||||
{ "kind": "added" }
|
||||
] }
|
||||
] }
|
||||
] }
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
val result = decodeDiffResult(json)
|
||||
|
||||
assertTrue(result.staged)
|
||||
assertTrue(result.truncated)
|
||||
assertEquals(1, result.files.size) // the newPath-less file was dropped
|
||||
val file = result.files.single()
|
||||
assertEquals("keep.kt", file.newPath)
|
||||
assertEquals(1, file.hunks.size) // the header-less hunk was dropped
|
||||
assertEquals("@@ -1 +1 @@", file.hunks.single().header)
|
||||
assertEquals(1, file.hunks.single().lines.size) // the text-less line was dropped
|
||||
assertEquals("ok", file.hunks.single().lines.single().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode of a non-object or unparseable body yields an empty result, never throws`() {
|
||||
assertEquals(0, decodeDiffResult("[]".toByteArray()).files.size)
|
||||
assertEquals(0, decodeDiffResult("not json".toByteArray()).files.size)
|
||||
assertEquals(0, decodeDiffResult(ByteArray(0)).files.size)
|
||||
}
|
||||
|
||||
// ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
|
||||
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
|
||||
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
|
||||
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
|
||||
calls += staged
|
||||
error?.let { throw it }
|
||||
return result!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun oneFileResult(staged: Boolean) = DiffResult(
|
||||
files = listOf(DiffFile("a", "a", "modified", 1, 0, false, listOf(DiffHunk("@@", listOf(DiffLine(DiffLineKind.ADDED, "x")))))),
|
||||
staged = staged, truncated = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `bind loads then transitions to LOADED with flattened rows`() = runTest {
|
||||
val fetcher = FakeFetcher(oneFileResult(false))
|
||||
val vm = DiffViewModel(fetcher, path = "/repo")
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
|
||||
vm.bind(scope)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(DiffPhase.LOADED, vm.uiState.value.phase)
|
||||
assertTrue(vm.uiState.value.rows.isNotEmpty())
|
||||
assertEquals(listOf(false), fetcher.calls) // fetched the working tree (staged=false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty diff is EMPTY and a fetch error is ERROR`() = runTest {
|
||||
val emptyVm = DiffViewModel(FakeFetcher(DiffResult(emptyList(), false, false)), "/repo")
|
||||
val errVm = DiffViewModel(FakeFetcher(null, error = RuntimeException("boom")), "/repo")
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
|
||||
emptyVm.bind(scope); errVm.bind(scope)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(DiffPhase.EMPTY, emptyVm.uiState.value.phase)
|
||||
assertEquals(DiffPhase.ERROR, errVm.uiState.value.phase)
|
||||
assertTrue(errVm.uiState.value.rows.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectStaged toggles the flag and re-fetches with staged true, but is a no-op for the same value`() = runTest {
|
||||
val fetcher = FakeFetcher(oneFileResult(true))
|
||||
val vm = DiffViewModel(fetcher, "/repo")
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
|
||||
vm.bind(scope); advanceUntilIdle() // initial fetch: staged=false
|
||||
vm.selectStaged(true); advanceUntilIdle() // toggles → re-fetch staged=true
|
||||
vm.selectStaged(true); advanceUntilIdle() // same value → NO re-fetch
|
||||
|
||||
assertTrue(vm.uiState.value.staged)
|
||||
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.session.AwayDigest
|
||||
import wang.yaojia.webterm.session.Digest
|
||||
import wang.yaojia.webterm.session.Exited
|
||||
import wang.yaojia.webterm.session.Gate
|
||||
import wang.yaojia.webterm.session.GateState
|
||||
import wang.yaojia.webterm.session.SessionEvent
|
||||
import wang.yaojia.webterm.session.Telemetry
|
||||
import wang.yaojia.webterm.wire.ApproveMode
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
import wang.yaojia.webterm.wire.GateKind
|
||||
import wang.yaojia.webterm.wire.StatusTelemetry
|
||||
import wang.yaojia.webterm.wire.TimelineEvent
|
||||
import wang.yaojia.webterm.wiring.TerminalSessionController
|
||||
|
||||
/**
|
||||
* [GateViewModel] (A22) — the two-line epoch stale-guard, arrival-only haptics, telemetry hold, and
|
||||
* digest suppression. Pure JVM under `runTest`; an [UnconfinedTestDispatcher] runs the event
|
||||
* collector eagerly so an [FakeController.emit] is processed inline before the assertion. A hand-fake
|
||||
* [TerminalSessionController] both drives the control stream and records every `decideGate` so
|
||||
* "exactly one, right mode" is assertable. Compose sheets/cards/haptic firing are device-QA (§7).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class GateViewModelTest {
|
||||
|
||||
/**
|
||||
* Fake control-stream source + decision recorder. [controlEvents] is backed by an UNBOUNDED channel
|
||||
* (mirrors the real per-consumer [EventBus] mailbox) so an [emit] before the collector subscribes is
|
||||
* buffered, not dropped, and the collect loop yields per element (arrivals interleave correctly). The
|
||||
* VM captures ONE mailbox at construction (FIX 1), so returning `channel.receiveAsFlow()` per call is
|
||||
* faithful — this fake models a single VM's mailbox.
|
||||
*/
|
||||
private class FakeController : TerminalSessionController {
|
||||
private val channel = Channel<SessionEvent>(Channel.UNLIMITED)
|
||||
val decisions = mutableListOf<Pair<Int, ClientMessage>>()
|
||||
|
||||
override fun controlEvents(): Flow<SessionEvent> = channel.receiveAsFlow()
|
||||
override val output: Flow<ByteArray> = emptyFlow()
|
||||
override fun start() = Unit
|
||||
override fun sendInput(data: String) = Unit
|
||||
override fun resize(cols: Int, rows: Int) = Unit
|
||||
override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit
|
||||
override fun decideGate(epoch: Int, message: ClientMessage) {
|
||||
decisions += epoch to message
|
||||
}
|
||||
|
||||
override fun close() = Unit
|
||||
|
||||
fun emit(event: SessionEvent) {
|
||||
channel.trySend(event) // UNLIMITED → never fails, never suspends.
|
||||
}
|
||||
}
|
||||
|
||||
private fun toolGate(epoch: Int) = Gate(GateState(kind = GateKind.TOOL, detail = "Bash", epoch = epoch))
|
||||
private fun planGate(epoch: Int) = Gate(GateState(kind = GateKind.PLAN, detail = "plan", epoch = epoch))
|
||||
|
||||
// ── Two-line stale-guard ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a decision for a stale epoch is dropped`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
// The live gate has already advanced to epoch 2 (the old gate resolved, a new one rose).
|
||||
fake.emit(toolGate(2))
|
||||
|
||||
// A slow tap against the RESOLVED epoch-1 gate must approve NOTHING (防误批新 gate).
|
||||
vm.decide(GateDecision.APPROVE, epoch = 1)
|
||||
|
||||
assertTrue(fake.decisions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a decision with no gate held is dropped`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
// Gate rose then fell (falling edge → no gate held).
|
||||
fake.emit(toolGate(1))
|
||||
fake.emit(Gate(null))
|
||||
|
||||
vm.decide(GateDecision.APPROVE, epoch = 1)
|
||||
|
||||
assertTrue(fake.decisions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a current-epoch tool approve sends exactly one plain approve`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
fake.emit(toolGate(2))
|
||||
|
||||
vm.decide(GateDecision.APPROVE, epoch = 2)
|
||||
|
||||
assertEquals(listOf(2 to ClientMessage.Approve(mode = null)), fake.decisions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a current-epoch tool reject sends exactly one reject`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
fake.emit(toolGate(4))
|
||||
|
||||
vm.decide(GateDecision.REJECT, epoch = 4)
|
||||
|
||||
assertEquals(listOf<Pair<Int, ClientMessage>>(4 to ClientMessage.Reject), fake.decisions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptEdits on a tool gate is dropped (not an affordance of that kind)`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
fake.emit(toolGate(1))
|
||||
|
||||
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1)
|
||||
|
||||
assertTrue(fake.decisions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan decisions resolve to the top-level approve mode key`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
fake.emit(planGate(3))
|
||||
|
||||
vm.decide(GateDecision.APPROVE, epoch = 3) // default review
|
||||
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 3) // auto-accept
|
||||
vm.decide(GateDecision.REJECT, epoch = 3) // keep planning
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
3 to ClientMessage.Approve(mode = ApproveMode.DEFAULT),
|
||||
3 to ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS),
|
||||
3 to ClientMessage.Reject,
|
||||
),
|
||||
fake.decisions,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Haptic arrival (rising-edge only) ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `gateArrivals fires once per new gate — never on refresh or falling edge`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
val arrivals = mutableListOf<Unit>()
|
||||
backgroundScope.launch { vm.gateArrivals.collect { arrivals += it } }
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
fake.emit(toolGate(1)) // rising edge → arrival #1
|
||||
fake.emit(toolGate(1)) // same epoch refresh → NO arrival
|
||||
fake.emit(Gate(null)) // falling edge → NO arrival
|
||||
fake.emit(toolGate(2)) // new gate → arrival #2
|
||||
|
||||
assertEquals(2, arrivals.size)
|
||||
}
|
||||
|
||||
// ── Telemetry hold + digest suppression ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `latest telemetry is held in ui state`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
val telemetry = StatusTelemetry(model = "opus", at = 1_000L)
|
||||
|
||||
fake.emit(Telemetry(telemetry))
|
||||
|
||||
assertEquals(telemetry, vm.uiState.value.telemetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-empty away digest is shown and an all-zero digest is suppressed`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
val nonEmpty = AwayDigest(
|
||||
toolRuns = 2,
|
||||
waitingCount = 1,
|
||||
sawDone = false,
|
||||
sawStuck = false,
|
||||
recent = listOf(TimelineEvent(at = 5L, eventClass = "tool", label = "ran Bash")),
|
||||
)
|
||||
|
||||
fake.emit(Digest(nonEmpty))
|
||||
assertEquals(nonEmpty, vm.uiState.value.digest)
|
||||
|
||||
// A later reconnect with nothing to report suppresses the banner (holds null, not EMPTY).
|
||||
fake.emit(Digest(AwayDigest.EMPTY))
|
||||
assertNull(vm.uiState.value.digest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exit clears a held gate so no stale card lingers`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val fake = FakeController()
|
||||
val vm = GateViewModel(fake)
|
||||
vm.bind(backgroundScope)
|
||||
fake.emit(toolGate(1))
|
||||
assertEquals(1, vm.uiState.value.gate?.epoch)
|
||||
|
||||
fake.emit(Exited(code = 0))
|
||||
|
||||
assertNull(vm.uiState.value.gate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* [PairingViewModel] (A19) — the JVM-tested pairing core: §5.4 warning-tier mapping, the
|
||||
* confirm-before-network gate, the public-host explicit-ack gate, and the tunnel-host cert-gate choke
|
||||
* point that retry cannot bypass. Camera/Compose/permission UI is device-QA (plan §7). Pure `runTest`
|
||||
* with an [UnconfinedTestDispatcher] so a probe launched into `backgroundScope` runs inline.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PairingViewModelTest {
|
||||
|
||||
/** Records every probe call so "no probe before confirm / cert-gate refuses" is directly assertable. */
|
||||
private class FakeProber(var result: (HostEndpoint) -> PairingProbeResult) : PairingProber {
|
||||
val calls = mutableListOf<HostEndpoint>()
|
||||
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult {
|
||||
calls += endpoint
|
||||
return result(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun endpoint(url: String): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
|
||||
|
||||
private fun success(): (HostEndpoint) -> PairingProbeResult = { PairingProbeResult.Success(it) }
|
||||
|
||||
private fun newVm(
|
||||
prober: PairingProber,
|
||||
hasCert: Boolean = false,
|
||||
store: InMemoryHostStore = InMemoryHostStore(),
|
||||
) = PairingViewModel(
|
||||
hostStore = store,
|
||||
prober = prober,
|
||||
hasDeviceCert = { hasCert },
|
||||
newId = { "fixed-id" },
|
||||
)
|
||||
|
||||
// ── §5.4 warning-tier mapping (each host class → tier + which needs explicit ack) ────────────────
|
||||
|
||||
@Test
|
||||
fun `each host class maps to the correct warning tier`() {
|
||||
val table = mapOf(
|
||||
"http://localhost:3000" to WarningTier.LOOPBACK,
|
||||
"http://127.0.0.1:3000" to WarningTier.LOOPBACK,
|
||||
"http://10.0.0.5:3000" to WarningTier.PRIVATE_LAN,
|
||||
"http://192.168.1.10:3000" to WarningTier.PRIVATE_LAN,
|
||||
"http://172.16.9.9:3000" to WarningTier.PRIVATE_LAN,
|
||||
"http://mac.local:3000" to WarningTier.PRIVATE_LAN,
|
||||
"http://100.64.0.1:3000" to WarningTier.TAILSCALE,
|
||||
"https://box.ts.net" to WarningTier.TAILSCALE,
|
||||
"https://star.terminal.yaojia.wang" to WarningTier.TUNNEL,
|
||||
"https://example.org:3000" to WarningTier.PUBLIC,
|
||||
"https://8.8.8.8" to WarningTier.PUBLIC,
|
||||
)
|
||||
table.forEach { (url, expected) ->
|
||||
assertEquals(expected, PairingTiers.tierFor(endpoint(url)), "tier for $url")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unclassifiable host is public (fail-safe)`() {
|
||||
assertEquals(WarningTier.PUBLIC, PairingTiers.tierFor(endpoint("https://weird-unknown.internal")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a public host needs the explicit acknowledge and only the tunnel host is cert-gated`() {
|
||||
assertFalse(WarningTier.LOOPBACK.needsExplicitAck)
|
||||
assertFalse(WarningTier.PRIVATE_LAN.needsExplicitAck)
|
||||
assertFalse(WarningTier.TAILSCALE.needsExplicitAck)
|
||||
assertFalse(WarningTier.TUNNEL.needsExplicitAck)
|
||||
assertTrue(WarningTier.PUBLIC.needsExplicitAck)
|
||||
|
||||
assertTrue(WarningTier.TUNNEL.isCertGated)
|
||||
assertFalse(WarningTier.PUBLIC.isCertGated)
|
||||
assertFalse(WarningTier.PRIVATE_LAN.isCertGated)
|
||||
}
|
||||
|
||||
// ── Confirm-before-network gate ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a scanned URL is validated and shown for confirmation but never probed`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(success())
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onQrScanned("http://10.0.0.5:3000")
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertInstanceOf(PairingUiState.Confirming::class.java, state)
|
||||
assertEquals(WarningTier.PRIVATE_LAN, (state as PairingUiState.Confirming).tier)
|
||||
assertTrue(prober.calls.isEmpty(), "confirm-before-network: no probe until confirm")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an invalid URL yields an entry error and no probe`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(success())
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry("not a url")
|
||||
|
||||
assertEquals(PairingUiState.Entry(EntryError.INVALID_URL), vm.uiState.value)
|
||||
assertTrue(prober.calls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirming a LAN host probes it and saves on success`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(success())
|
||||
val store = InMemoryHostStore()
|
||||
val vm = newVm(prober, store = store)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry("http://10.0.0.5:3000")
|
||||
vm.confirm()
|
||||
|
||||
assertEquals(listOf(endpoint("http://10.0.0.5:3000")), prober.calls)
|
||||
val state = vm.uiState.value
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, state)
|
||||
val saved = store.loadAll()
|
||||
assertEquals(1, saved.size)
|
||||
assertEquals("http://10.0.0.5:3000", saved.single().endpoint.baseUrl)
|
||||
assertEquals("fixed-id", saved.single().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a probe failure maps to inert copy and is retryable`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber({ PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) })
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry("http://10.0.0.5:3000")
|
||||
vm.confirm()
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertInstanceOf(PairingUiState.Failed::class.java, state)
|
||||
assertEquals(PairingError.HttpOkButNotWebTerminal, (state as PairingUiState.Failed).error)
|
||||
assertTrue(state.message.isNotBlank())
|
||||
}
|
||||
|
||||
// ── Public-host explicit acknowledge ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a public host is not probed until the risk is explicitly acknowledged`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(success())
|
||||
val vm = newVm(prober)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry("https://example.org:3000")
|
||||
|
||||
// Confirm WITHOUT acknowledgement → no probe, ack reminder re-armed.
|
||||
vm.confirm(acknowledgedPublicRisk = false)
|
||||
assertTrue(prober.calls.isEmpty(), "public host must not probe without an explicit ack")
|
||||
assertTrue((vm.uiState.value as PairingUiState.Confirming).awaitingAck)
|
||||
|
||||
// Confirm WITH acknowledgement → probes.
|
||||
vm.confirm(acknowledgedPublicRisk = true)
|
||||
assertEquals(1, prober.calls.size)
|
||||
}
|
||||
|
||||
// ── Tunnel-host cert-gate (the choke point retry cannot bypass) ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a tunnel host is refused without a device cert and retry cannot bypass the gate`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(success())
|
||||
val vm = newVm(prober, hasCert = false)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry("https://star.terminal.yaojia.wang")
|
||||
|
||||
vm.confirm()
|
||||
assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value)
|
||||
assertTrue(prober.calls.isEmpty(), "cert-gate: no network I/O without a device cert")
|
||||
|
||||
// Retrying funnels through the SAME choke point — it still refuses, still no probe.
|
||||
vm.retry()
|
||||
assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value)
|
||||
assertTrue(prober.calls.isEmpty(), "retry must not bypass the tunnel cert-gate")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tunnel host with a device cert installed probes normally`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(success())
|
||||
val store = InMemoryHostStore()
|
||||
val vm = newVm(prober, hasCert = true, store = store)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry("https://star.terminal.yaojia.wang")
|
||||
vm.confirm()
|
||||
|
||||
assertEquals(1, prober.calls.size)
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(1, store.loadAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tunnel TLS failure is re-mapped to a client-cert message`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber({ PairingProbeResult.Failure(PairingError.TlsFailure) })
|
||||
val vm = newVm(prober, hasCert = true)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry("https://star.terminal.yaojia.wang")
|
||||
vm.confirm()
|
||||
|
||||
val state = vm.uiState.value as PairingUiState.Failed
|
||||
assertTrue(state.message.contains("客户端证书"), "tunnel TLS failure → client-cert copy")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||
import wang.yaojia.webterm.api.models.ProjectSessionRef
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* A23 Projects logic (JVM). The byte-parity contract: [ProjectGrouping] produces the SAME group KEYS as
|
||||
* web `public/projects.ts` / iOS `ProjectGrouping` (collapse state is persisted by key into the shared
|
||||
* `/prefs`), and the [ProjectsViewModel] prefs round-trip preserves unknown top-level keys + never PUTs
|
||||
* when `/prefs` never loaded (R11). Grid/sheet layout is device-QA (plan §7).
|
||||
*/
|
||||
class ProjectsViewModelTest {
|
||||
|
||||
// ── ProjectGrouping: exact group-key parity ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `group keys are byte-identical to web - namespace first-seen casing, case-insensitive bucket, sentinel other`() {
|
||||
val projects = listOf(
|
||||
proj("Acme.Web.frontend", "/a1", lastActive = 10),
|
||||
proj("Acme.Web.backend", "/a2", lastActive = 20),
|
||||
// lower-cased name → SAME bucket as "Acme.Web" (case-insensitive), first-seen casing wins.
|
||||
proj("acme.web.tools", "/a3", lastActive = 5),
|
||||
proj("solo", "/s1", lastActive = 1), // no dot → "other"
|
||||
proj("Foo.Bar", "/f1", lastActive = 1), // single-member namespace → collapses into "other"
|
||||
)
|
||||
|
||||
val groups = ProjectGrouping.group(projects, favourites = emptySet())
|
||||
|
||||
// Frozen keys: the first-seen-cased namespace, then the leading-space sentinel " other".
|
||||
assertEquals(listOf("Acme.Web", " other"), groups.map { it.key })
|
||||
assertEquals(ProjectGroupKind.NAMESPACE, groups[0].kind)
|
||||
assertEquals(3, groups[0].projects.size) // all three Acme.Web.* including the lower-cased one
|
||||
assertEquals(ProjectGroupKind.OTHER, groups[1].kind)
|
||||
assertEquals(2, groups[1].projects.size) // solo + Foo.Bar
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `displayLabel strips the namespace prefix case-insensitively, sentinels keep the full name`() {
|
||||
assertEquals("frontend", ProjectGrouping.displayLabel("Acme.Web.frontend", "Acme.Web"))
|
||||
assertEquals("tools", ProjectGrouping.displayLabel("acme.web.tools", "Acme.Web"))
|
||||
// A non-matching name is returned whole; sentinel groups never strip.
|
||||
assertEquals("other", ProjectGrouping.displayLabel("other", ProjectGrouping.OTHER_GROUP_KEY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running session pins an Active now group with the frozen space-active key`() {
|
||||
val groups = ProjectGrouping.group(
|
||||
listOf(
|
||||
proj("Acme.Web.frontend", "/a1", running = true),
|
||||
proj("Acme.Web.backend", "/a2"),
|
||||
),
|
||||
favourites = emptySet(),
|
||||
)
|
||||
assertEquals(ProjectGrouping.ACTIVE_GROUP_KEY, groups.first().key) // " active"
|
||||
assertEquals(ProjectGroupKind.ACTIVE, groups.first().kind)
|
||||
assertTrue(groups.any { it.key == "Acme.Web" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no shared namespace falls back to a single flat group keyed space-other`() {
|
||||
val groups = ProjectGrouping.group(listOf(proj("solo", "/s1"), proj("plain", "/p1")), emptySet())
|
||||
assertEquals(1, groups.size)
|
||||
assertEquals(ProjectGrouping.OTHER_GROUP_KEY, groups.single().key) // " other"
|
||||
assertEquals(ProjectGroupKind.FLAT, groups.single().kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sort puts favourites first then recency descending, stable on ties`() {
|
||||
val a = proj("a", "/a", lastActive = 100)
|
||||
val b = proj("b", "/b", lastActive = 50)
|
||||
val sorted = ProjectGrouping.sort(listOf(a, b), favourites = setOf("/b"))
|
||||
assertEquals(listOf("/b", "/a"), sorted.map { it.path }) // fav /b first despite lower recency
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filter matches name or path substring case-insensitively`() {
|
||||
val projects = listOf(proj("Acme.Web", "/srv/acme"), proj("Widget", "/srv/widget"))
|
||||
assertEquals(listOf("/srv/acme"), ProjectGrouping.filter(projects, "ACME").map { it.path })
|
||||
assertEquals(listOf("/srv/widget"), ProjectGrouping.filter(projects, "widget").map { it.path })
|
||||
assertEquals(2, ProjectGrouping.filter(projects, " ").size) // blank → all
|
||||
}
|
||||
|
||||
// ── /prefs unknown-key round-trip (R11) ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `toggling a favourite preserves unknown top-level keys and keeps an Int an Int, not a Double`() = runTest {
|
||||
val json = """{"favourites":["/a"],"collapsed":{"Acme.Web":true},"schemaVersion":3,"nested":{"x":1}}"""
|
||||
val gateway = FakeGateway(prefsValue = UiPrefs.decode(json.toByteArray())!!)
|
||||
val vm = ProjectsViewModel(gateway)
|
||||
|
||||
vm.load()
|
||||
vm.toggleFavourite("/b")
|
||||
|
||||
assertEquals(1, gateway.putCalls.size)
|
||||
val body = gateway.putCalls.single().encodeBody().decodeToString()
|
||||
// Unknown top-level keys are carried through verbatim …
|
||||
assertTrue(body.contains("\"schemaVersion\":3"), "unknown Int key preserved as an Int: $body")
|
||||
assertTrue(body.contains("\"nested\""), "unknown nested key preserved: $body")
|
||||
// … and the Int never widened to a Double.
|
||||
assertFalse(body.contains("3.0"), "Int must not re-encode as a Double: $body")
|
||||
// The known key was rewritten (new favourite appended, order preserved).
|
||||
val echoed = UiPrefs.decode(gateway.putCalls.single().encodeBody())!!
|
||||
assertEquals(listOf("/a", "/b"), echoed.favourites)
|
||||
assertEquals(mapOf("Acme.Web" to true), echoed.collapsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a toggle NEVER PUTs when prefs never loaded - it stays local-only (R11 anti-clobber)`() = runTest {
|
||||
val gateway = FakeGateway(prefsThrows = true) // GET /prefs failed → no base blob
|
||||
val vm = ProjectsViewModel(gateway)
|
||||
|
||||
vm.load()
|
||||
vm.toggleFavourite("/x")
|
||||
|
||||
assertEquals(0, gateway.putCalls.size, "an empty-base PUT would wipe the server's favourites")
|
||||
assertTrue(vm.uiState.value.favourites.contains("/x")) // still applied locally
|
||||
assertEquals(ProjectsCopy.PREFS_LOAD_FAILED, vm.uiState.value.prefsError)
|
||||
}
|
||||
|
||||
// ── favourite / collapse logic ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `toggleCollapsed adds then removes the key and PUTs each change`() = runTest {
|
||||
val gateway = FakeGateway(prefsValue = UiPrefs.create())
|
||||
val vm = ProjectsViewModel(gateway)
|
||||
vm.load()
|
||||
|
||||
vm.toggleCollapsed("Acme.Web")
|
||||
assertEquals(mapOf("Acme.Web" to true), vm.uiState.value.collapsedGroups)
|
||||
|
||||
vm.toggleCollapsed("Acme.Web") // expanded is the default → the key is dropped, not set false
|
||||
assertTrue(vm.uiState.value.collapsedGroups.isEmpty())
|
||||
assertEquals(2, gateway.putCalls.size)
|
||||
assertTrue(gateway.putCalls.last().collapsed.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCollapsed honours state but a search force-expands every section`() {
|
||||
val group = ProjectGroup("Acme.Web", "Acme.Web", ProjectGroupKind.NAMESPACE, emptyList(), activeCount = 0)
|
||||
val collapsed = ProjectsUiState(collapsedGroups = mapOf("Acme.Web" to true))
|
||||
assertTrue(collapsed.isCollapsed(group))
|
||||
// Searching forces the section open so results never hide behind a collapsed caret.
|
||||
assertFalse(collapsed.copy(searchText = "acme").isCollapsed(group))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleFavourite is a set-like toggle and adopts the server echo`() = runTest {
|
||||
val gateway = FakeGateway(prefsValue = UiPrefs.create())
|
||||
val vm = ProjectsViewModel(gateway)
|
||||
vm.load()
|
||||
|
||||
vm.toggleFavourite("/a")
|
||||
assertEquals(listOf("/a"), vm.uiState.value.favourites)
|
||||
vm.toggleFavourite("/a") // toggle off
|
||||
assertTrue(vm.uiState.value.favourites.isEmpty())
|
||||
}
|
||||
|
||||
// ── open Claude here ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `requestOpenClaude mints an attach-null-cwd request for an absolute path and rejects a relative one`() = runTest {
|
||||
val vm = ProjectsViewModel(FakeGateway())
|
||||
|
||||
vm.requestOpenClaude("relative/path")
|
||||
assertNull(vm.uiState.value.openRequest)
|
||||
assertEquals(ProjectsCopy.OPEN_CLAUDE_INVALID_PATH, vm.uiState.value.openError)
|
||||
|
||||
vm.requestOpenClaude("/home/dev/app")
|
||||
val request = vm.uiState.value.openRequest
|
||||
assertEquals("/home/dev/app", request?.cwd)
|
||||
assertEquals("claude\r", request?.bootstrapInput) // Enter is \r, not \n
|
||||
assertNull(vm.uiState.value.openError)
|
||||
}
|
||||
|
||||
// ── Fakes / helpers ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private class FakeGateway(
|
||||
private val projectList: List<ProjectInfo> = emptyList(),
|
||||
private val prefsValue: UiPrefs? = UiPrefs.create(),
|
||||
private val prefsThrows: Boolean = false,
|
||||
) : ProjectsGateway {
|
||||
val putCalls = mutableListOf<UiPrefs>()
|
||||
override suspend fun projects(): List<ProjectInfo> = projectList
|
||||
override suspend fun prefs(): UiPrefs {
|
||||
if (prefsThrows) throw RuntimeException("prefs unavailable")
|
||||
return prefsValue!!
|
||||
}
|
||||
|
||||
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs {
|
||||
putCalls += prefs
|
||||
return prefs // the sanitized server echo == input in this fake
|
||||
}
|
||||
|
||||
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
|
||||
}
|
||||
|
||||
private fun proj(
|
||||
name: String,
|
||||
path: String,
|
||||
lastActive: Long? = null,
|
||||
running: Boolean = false,
|
||||
): ProjectInfo = ProjectInfo(
|
||||
name = name,
|
||||
path = path,
|
||||
isGit = false,
|
||||
branch = null,
|
||||
dirty = null,
|
||||
lastActiveMs = lastActive,
|
||||
sessions = if (running) {
|
||||
listOf(ProjectSessionRef(UUID.randomUUID(), null, ClaudeStatus.UNKNOWN, clientCount = 1, createdAt = 0L, exited = false))
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
import wang.yaojia.webterm.designsystem.DisplayStatus
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.session.UnreadLedger
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* [SessionListViewModel] (A20) — the poll→row mapping, unread-dot logic (watermark vs `lastOutputAt`,
|
||||
* cleared on open), optimistic swipe-kill (404 = already-gone = success), lossy-list tolerance, and the
|
||||
* multi-host switch. Pure JVM under `runTest`; the poll loop launches on an [UnconfinedTestDispatcher] so
|
||||
* the first fetch runs inline before the first `delay`. Compose/swipe/thumbnails are device-QA (plan §7).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SessionListViewModelTest {
|
||||
|
||||
private companion object {
|
||||
const val BASE_A = "http://a:3000"
|
||||
const val BASE_B = "http://b:3000"
|
||||
val ID_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
val ID_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")
|
||||
|
||||
fun host(id: String, name: String, baseUrl: String, cert: Boolean = false): Host =
|
||||
Host.create(id = id, name = name, baseUrl = baseUrl, hasDeviceCert = cert)!!
|
||||
|
||||
fun session(
|
||||
id: UUID,
|
||||
status: ClaudeStatus = ClaudeStatus.WORKING,
|
||||
exited: Boolean = false,
|
||||
title: String? = null,
|
||||
cols: Int = 161,
|
||||
rows: Int = 50,
|
||||
lastOutputAt: Long? = null,
|
||||
) = LiveSessionInfo(
|
||||
id = id,
|
||||
createdAt = 1_700_000_000_000L,
|
||||
clientCount = 1,
|
||||
status = status,
|
||||
exited = exited,
|
||||
cwd = null,
|
||||
title = title,
|
||||
cols = cols,
|
||||
rows = rows,
|
||||
telemetry = null,
|
||||
lastOutputAt = lastOutputAt,
|
||||
)
|
||||
}
|
||||
|
||||
/** In-memory [HostStore] whose [loadAll] returns a fixed list (mutations unused by the VM's reads). */
|
||||
private class FakeHostStore(private val hosts: List<Host>) : HostStore {
|
||||
override suspend fun loadAll(): List<Host> = hosts
|
||||
override suspend fun upsert(host: Host): List<Host> = hosts
|
||||
override suspend fun remove(id: String): List<Host> = hosts
|
||||
}
|
||||
|
||||
/** Fake gateway: returns a canned list and records kills; [killError] scripts the DELETE outcome. */
|
||||
private class FakeGateway(
|
||||
private val list: List<LiveSessionInfo>,
|
||||
private val killError: Throwable? = null,
|
||||
) : SessionListGateway {
|
||||
val killed = mutableListOf<UUID>()
|
||||
override suspend fun liveSessions(): List<LiveSessionInfo> = list
|
||||
override suspend fun killSession(id: UUID) {
|
||||
killed += id
|
||||
killError?.let { throw it }
|
||||
}
|
||||
}
|
||||
|
||||
// ── poll → row mapping ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `poll maps live sessions to rows with status title and dimensions`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val gateway = FakeGateway(
|
||||
listOf(
|
||||
session(ID_1, status = ClaudeStatus.WORKING, title = "web-terminal", cols = 161, rows = 50),
|
||||
session(ID_2, exited = true, title = " gone ", cols = 80, rows = 24),
|
||||
),
|
||||
)
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { gateway },
|
||||
)
|
||||
|
||||
backgroundScope.launch { vm.poll() }
|
||||
|
||||
val rows = vm.uiState.value.rows
|
||||
assertEquals(2, rows.size)
|
||||
assertEquals(DisplayStatus.Working, rows[0].displayStatus)
|
||||
assertEquals("web-terminal", rows[0].title)
|
||||
assertEquals("161×50", rows[0].dimensions)
|
||||
// exited outranks status; the zero-width char is stripped by TitleSanitizer.
|
||||
assertEquals(DisplayStatus.Exited, rows[1].displayStatus)
|
||||
assertEquals("gone", rows[1].title)
|
||||
}
|
||||
|
||||
// ── unread dot ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a session with output newer than its watermark is unread and clears on open`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val gateway = FakeGateway(listOf(session(ID_1, lastOutputAt = 100L)))
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { gateway },
|
||||
watermarkStore = InMemoryUnreadWatermarkStore(), // empty → watermark 0 → unread
|
||||
)
|
||||
|
||||
vm.refresh()
|
||||
assertTrue(vm.uiState.value.rows.single().isUnread)
|
||||
|
||||
// Opening the session marks it seen at its lastOutputAt → the dot clears immediately.
|
||||
vm.markSeen(ID_1)
|
||||
assertFalse(vm.uiState.value.rows.single().isUnread)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null lastOutputAt is never unread`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = null))) },
|
||||
)
|
||||
|
||||
vm.refresh()
|
||||
|
||||
assertFalse(vm.uiState.value.rows.single().isUnread)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an already-seen session (watermark at or past lastOutputAt) shows no dot`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val seen = UnreadLedger().record(ID_1.toString(), 100L)
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
|
||||
watermarkStore = InMemoryUnreadWatermarkStore(seen),
|
||||
)
|
||||
|
||||
vm.refresh()
|
||||
|
||||
assertFalse(vm.uiState.value.rows.single().isUnread)
|
||||
}
|
||||
|
||||
// ── swipe-to-kill ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `killSession removes the row optimistically and issues the delete`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val gateway = FakeGateway(listOf(session(ID_1), session(ID_2)))
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { gateway },
|
||||
)
|
||||
vm.refresh()
|
||||
assertEquals(2, vm.uiState.value.rows.size)
|
||||
|
||||
vm.killSession(ID_1)
|
||||
|
||||
assertEquals(listOf(ID_2), vm.uiState.value.rows.map { it.id })
|
||||
assertEquals(listOf(ID_1), gateway.killed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 404 on kill is treated as success — the row stays removed`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val gateway = FakeGateway(listOf(session(ID_1)), killError = ApiClientError.SessionNotFound)
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { gateway },
|
||||
)
|
||||
vm.refresh()
|
||||
|
||||
vm.killSession(ID_1)
|
||||
|
||||
assertTrue(vm.uiState.value.rows.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real kill failure restores the row`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val gateway = FakeGateway(listOf(session(ID_1), session(ID_2)), killError = ApiClientError.Forbidden)
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { gateway },
|
||||
)
|
||||
vm.refresh()
|
||||
|
||||
vm.killSession(ID_1)
|
||||
|
||||
// The DELETE was rejected (403) → the session is still alive → the row is restored, in place.
|
||||
assertEquals(listOf(ID_1, ID_2), vm.uiState.value.rows.map { it.id })
|
||||
}
|
||||
|
||||
// ── lossy list decode tolerance (real ApiClient + fake transport) ────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a malformed entry is dropped end-to-end and the rest become rows`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val transport = FakeHttpTransport()
|
||||
val body = """
|
||||
[
|
||||
{"id":"${ID_1}","createdAt":1,"clientCount":1,"status":"working","exited":false,"cols":80,"rows":24},
|
||||
{"id":"not-a-uuid","createdAt":1,"clientCount":1,"status":"idle","exited":false,"cols":80,"rows":24},
|
||||
{"id":"${ID_2}","createdAt":1,"clientCount":1,"status":"reticulating","exited":false,"cols":80,"rows":24}
|
||||
]
|
||||
""".trimIndent()
|
||||
transport.queueSuccess(url = "$BASE_A/live-sessions", body = body.toByteArray())
|
||||
val api = ApiClient(HostEndpoint.fromBaseUrl(BASE_A)!!, transport)
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { ApiClientSessionGateway(api) },
|
||||
)
|
||||
|
||||
vm.refresh()
|
||||
|
||||
val rows = vm.uiState.value.rows
|
||||
assertEquals(listOf(ID_1, ID_2), rows.map { it.id }) // bad entry dropped, order kept
|
||||
assertEquals(DisplayStatus.Unknown, rows[1].displayStatus) // unknown wire status survives
|
||||
assertFalse(vm.uiState.value.hasLoadError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transport failure flags the error banner without wiping the last-good rows`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
var failNext = false
|
||||
val gateway = object : SessionListGateway {
|
||||
override suspend fun liveSessions(): List<LiveSessionInfo> {
|
||||
if (failNext) throw ApiClientError.UnexpectedStatus(500)
|
||||
return listOf(session(ID_1))
|
||||
}
|
||||
override suspend fun killSession(id: UUID) = Unit
|
||||
}
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
|
||||
gatewayFactory = { gateway },
|
||||
)
|
||||
vm.refresh()
|
||||
assertEquals(1, vm.uiState.value.rows.size)
|
||||
|
||||
failNext = true
|
||||
vm.refresh()
|
||||
|
||||
assertTrue(vm.uiState.value.hasLoadError)
|
||||
assertEquals(1, vm.uiState.value.rows.size) // last-good rows retained
|
||||
}
|
||||
|
||||
// ── multi-host switch ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the first host is active by default and selectHost switches the list and the checkmark`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val gwA = FakeGateway(listOf(session(ID_1)))
|
||||
val gwB = FakeGateway(listOf(session(ID_2)))
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(
|
||||
listOf(host("h1", "laptop", BASE_A), host("h2", "desktop", BASE_B, cert = true)),
|
||||
),
|
||||
gatewayFactory = { h -> if (h.id == "h1") gwA else gwB },
|
||||
)
|
||||
|
||||
vm.refresh()
|
||||
assertEquals("h1", vm.uiState.value.activeHostId)
|
||||
assertEquals(listOf(ID_1), vm.uiState.value.rows.map { it.id })
|
||||
assertTrue(vm.uiState.value.hosts.first { it.id == "h1" }.isActive)
|
||||
assertFalse(vm.uiState.value.hosts.first { it.id == "h2" }.isActive)
|
||||
|
||||
vm.selectHost("h2")
|
||||
|
||||
assertEquals("h2", vm.uiState.value.activeHostId)
|
||||
assertEquals(listOf(ID_2), vm.uiState.value.rows.map { it.id })
|
||||
assertTrue(vm.uiState.value.hosts.first { it.id == "h2" }.isActive)
|
||||
assertTrue(vm.uiState.value.hosts.first { it.id == "h2" }.hasDeviceCert)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no paired host yields an empty chooser`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val vm = SessionListViewModel(
|
||||
hostStore = FakeHostStore(emptyList()),
|
||||
gatewayFactory = { error("no host → gateway must never be built") },
|
||||
)
|
||||
|
||||
vm.refresh()
|
||||
|
||||
assertNull(vm.uiState.value.activeHostId)
|
||||
assertTrue(vm.uiState.value.rows.isEmpty())
|
||||
assertTrue(vm.uiState.value.hosts.isEmpty())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user