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`
|
- **SDK location:** `/usr/local/share/android-commandlinetools`
|
||||||
(installed via `brew install --cask 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:
|
- **`android/local.properties`** (gitignored) points Gradle at it:
|
||||||
`sdk.dir=/usr/local/share/android-commandlinetools`.
|
`sdk.dir=/usr/local/share/android-commandlinetools`.
|
||||||
- **Shell env** (for `sdkmanager`/`adb`): `export ANDROID_HOME=/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.
|
compatible with Gradle 9.6.1.
|
||||||
- **Gotcha:** AGP 9 has **built-in Kotlin** — apply ONLY the android plugin. Adding
|
- **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".
|
`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):
|
To add more SDK pieces later (e.g. an emulator image for instrumented tests):
|
||||||
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.jvm)
|
alias(libs.plugins.kotlin.jvm)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.kover)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -25,3 +26,14 @@ dependencies {
|
|||||||
tasks.test {
|
tasks.test {
|
||||||
useJUnitPlatform()
|
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,
|
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
|
||||||
val exited: Boolean,
|
val exited: Boolean,
|
||||||
val cwd: String? = null,
|
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). */
|
/** Current PTY size (latest-writer-wins on the server). */
|
||||||
val cols: Int,
|
val cols: Int,
|
||||||
val rows: Int,
|
val rows: Int,
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// :app — the Android application (Compose UI, ViewModels, Hilt DI, FCM push,
|
// :app — the Android application (Compose Material 3 + Adaptive design system,
|
||||||
// DeepLinkRouter, DesignSystem, EventBus). Mirrors iOS App/WebTerm.
|
// Hilt DI skeleton, launcher MainActivity). Mirrors iOS App/WebTerm.
|
||||||
//
|
//
|
||||||
// SCAFFOLD STUB ONLY. This module is COMMENTED OUT in settings.gradle.kts because
|
// A13 establishes the Android UI-stack version matrix for every later Android task
|
||||||
// this build environment has NO Android SDK. The block below is the intended shape;
|
// (the app-stack baseline, analogous to how A1 established the JVM catalog). All
|
||||||
// it applies the Android Gradle Plugin, which cannot resolve without an SDK.
|
// UI versions are resolved in gradle/libs.versions.toml.
|
||||||
//
|
//
|
||||||
// TODO(android-sdk): uncomment the include in settings.gradle.kts AND this block,
|
// Plugins (AGP 9 has BUILT-IN Kotlin → never apply org.jetbrains.kotlin.android):
|
||||||
// add AGP + google() to pluginManagement, and provide local.properties -> sdk.dir.
|
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/*
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.ksp)
|
||||||
id("com.google.dagger.hilt.android")
|
alias(libs.plugins.hilt)
|
||||||
id("com.google.gms.google-services")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "wang.yaojia.webterm"
|
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 {
|
defaultConfig {
|
||||||
applicationId = "wang.yaojia.webterm"
|
applicationId = "wang.yaojia.webterm"
|
||||||
@@ -31,18 +32,91 @@ android {
|
|||||||
versionName = "0.1.0"
|
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 {
|
dependencies {
|
||||||
|
// Modules A15 wires into the DI graph.
|
||||||
implementation(project(":wire-protocol"))
|
implementation(project(":wire-protocol"))
|
||||||
implementation(project(":session-core"))
|
implementation(project(":session-core"))
|
||||||
implementation(project(":api-client"))
|
implementation(project(":api-client"))
|
||||||
|
implementation(project(":transport-okhttp"))
|
||||||
implementation(project(":client-tls"))
|
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(":client-tls-android"))
|
||||||
implementation(project(":host-registry"))
|
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(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