diff --git a/android/.gitignore b/android/.gitignore index 2c52a7b..96325e0 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -40,3 +40,6 @@ service-account*.json # Kover / test reports **/kover/ + +# Kotlin compiler session/error scratch (KGP 2.x writes this at the project root) +.kotlin/ diff --git a/android/DEVICE_QA_CHECKLIST.md b/android/DEVICE_QA_CHECKLIST.md index 060b3b6..1814c66 100644 --- a/android/DEVICE_QA_CHECKLIST.md +++ b/android/DEVICE_QA_CHECKLIST.md @@ -1,19 +1,133 @@ # 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. +Everything below is **device/emulator-only**: it cannot be proven by the JVM suite. The pure logic +underneath each item IS unit-tested (757 JVM tests across 10 modules, Kover ≥80% on the pure modules), +but the 2026-07-30 audit established the hard lesson this file now exists to enforce — **a green JVM +suite says nothing about whether the app works.** Three crash-on-first-keypress defects and a +cleartext posture that made bare-LAN connection impossible all sat behind 484 passing tests, because +no JVM test instantiates an Android `View` and no JVM test dials a socket. + +**Rules for this file:** tick a box ONLY after observing the behaviour on real hardware (or, where +noted, an emulator) — and say what you observed. Never tick something because the code looks right or +a unit test covers it. Unticked is the honest default. + +--- + +## Observed so far (the only on-device evidence that exists) + +**2026-07-30, minified `benchmark` variant, `webterm` AVD (android-35, arm64, software GPU):** + +- [x] The **R8-minified, resource-shrunk, non-debuggable APK installs and cold-starts.** `am start -W` + → `Status: ok`, `LaunchState: COLD`, `TotalTime: 1085 ms` (software-GPU emulator — not a + meaningful performance number, only proof of a clean start). Process stayed alive; + `logcat -b crash` empty; no `FATAL EXCEPTION`, `NoClassDefFoundError` or `ClassNotFoundException`. + This is the check that R8 keep rules are correct — the class of bug that appears ONLY in release. +- [x] **Cold-start route with no paired host → pairing screen**, rendered correctly under R8 + (`ColdStartPolicy`, A29): title 配对主机, the 手动输入/扫码 segmented control, the + `主机地址(http(s)://…)` field and a disabled 下一步 button. Compose + Material 3 + the design-token + theme + Hilt injection therefore all survive minification. +- [x] **`POST_NOTIFICATIONS` runtime prompt appears** on first launch (API 33+ path, A30). +- [x] **ML Kit component registration** no longer fails under R8 (`ComponentDiscovery` clean). Before + the `ComponentRegistrar` keep rule it logged + `NoSuchMethodException: com.google.mlkit.common.internal.CommonComponentRegistrar. []`, + which would have silently disabled **QR barcode scanning in release builds only**. + +**Expected/benign in the same run:** `W FirebaseApp: Default FirebaseApp failed to initialize because +no default options were found` — there is no `google-services.json` yet (see the deploy artifacts +below). A `System UI isn't responding` dialog also appeared; that is the emulator's own `com.android.systemui` +under software GPU, not this app (our process logged no ANR and stayed `topResumedActivity`). + +**Everything else in this file is unobserved.** Notably: no real WebTerm host has ever been connected +to from Android, no terminal bytes have ever been rendered on a device, and no push has ever arrived. + +--- ## Deploy artifacts to provide first (not built here) +- [ ] **Release signing credentials.** `:app:assembleRelease` deliberately FAILS at packaging until they + exist — it will never emit an unsigned APK. Provide `webterm.release.{storeFile,storePassword,keyAlias,keyPassword}` + in `android/keystore.properties` (see `keystore.properties.example`; confirm it is gitignored + first) or `android/local.properties`, or the `WEBTERM_RELEASE_*` env vars. Keep the keystore + out of the repo — a lost signing key cannot be recovered. - [ ] `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). + SHA-256 (for the verified App Link `autoVerify`). Read it with + `keytool -list -v -keystore -alias | grep SHA256`. Server-side: run `A33` + `src/push/fcm.ts` with the `FCM_*` env group (service-account key path + project id). + +--- + +## FIRST PRIORITY — confirm the 2026-07-30 blocker fixes on a device + +These were the defects that made the app unusable on hardware. All are fixed and JVM-verified; **none +has been exercised on a device.** Until this block is ticked, nothing further in this file is worth +running, because every one of these sits on the path to the terminal. + +- [ ] **Typing does not crash.** Soft-keyboard keys, Enter, Backspace via the installed + `WebTermTerminalViewClient` / `RemoteTerminalHostView` IME contract. (Was: NPE on the first + keypress — stock `TerminalView.onKeyDown` dereferences `mClient`, then `mTermSession`.) +- [ ] **Swipe-scrolling inside an alternate-screen TUI does not crash.** Claude Code IS an + alternate-screen TUI, so this is ordinary use, not an edge case: drag vertically while a Claude + session is running. Also test the fling and, on a device with a mouse/trackpad, wheel scroll + (`onGenericMotionEvent`). (Was: `doScroll` → `handleKeyCode` → `mTermSession` NPE.) +- [ ] **A password manager does not crash the app.** Open the app with an autofill service enabled and + focus the terminal. (Was: unguarded `autofill()` while the view advertised itself autofillable; + the subtree is now excluded from the autofill structure.) +- [ ] **`resize` actually reaches the PTY.** A full-screen TUI fills the screen instead of rendering + into an 80x24 box, and reflows on rotation. (Was: `updateSize` had zero call sites.) Confirm the + cols×rows the server reports match what `TerminalResizeDriver` computed from + `TerminalRenderer.getFontWidth()/getFontLineSpacing()`. +- [ ] **Bare-LAN `ws://` connects.** Pair with `http://:3000` and attach. (Was: + `network_security_config.xml` was a stub denying all cleartext while `HostEndpoint` derives + `ws://` from `http://`.) +- [ ] **The tunnel domain is still TLS-only.** `http://.terminal.yaojia.wang` must be refused + (the one hostname with `cleartextTrafficPermitted="false"`), and `OkHttpClientFactory` must + refuse a plaintext dial to a non-private host even if the config would allow it. +- [ ] **§6.4 device-switch reclaim.** Attach the same session from a phone and a tablet; the device you + last touched drives the PTY size (latest-writer-wins) and reclaims full screen on + pane-show/window-focus without a re-attach. +- [ ] **`WEBTERM_TOKEN` gate end-to-end** against a server started WITH the token: REST + the WS + upgrade both carry the cookie, it survives a process restart (sealed with Tink AEAD under an + AndroidKeyStore key), and a **seal failure persists nothing** rather than falling back to + plaintext. Also verify the cookie never appears in `logcat`. + ⚠️ **Blocked until the cookie jar + cipher are installed in `:app`'s DI** — until then the gate + is inert at runtime no matter what the server does. +- [ ] **Newly-wired surfaces that had ZERO call sites before this session** — each needs a first-ever + look: session-list **preview thumbnails** (`ThumbnailPipeline`), **quick-reply chips + palette** + (`QuickReply`), and the tablet **pointer context menu** (`PointerContextMenu`). +- [ ] **Newly-decoding server frames.** The `queue` frame ("N queued" badge) and `status.preview` were + both being dropped as undecodable — the latter meant remote approvals were **blind**. Confirm a + real gate now shows the command being approved, and that a malformed preview does not cost the + whole frame (the gate must still appear). + +--- + +## KNOWN FEATURE GAP — copy-out via text selection is unavailable (deliberate) + +**Not a bug to be fixed by re-enabling stock selection.** At the pinned Termux v0.118.0, the stock +`ActionMode`'s own handlers dereference the `TerminalSession` this app deliberately does not have +(`TextSelectionCursorController$1.onActionItemClicked`, offsets 89-100 for Copy and 126-136 for Paste). +`TerminalSession` is `final` and its constructor forks a local process over JNI — exactly what a remote +terminal must not do (plan §6.1). So making the selection UI reachable would put a guaranteed NPE on +the **Copy** button, which is worse than not offering it. Long press is therefore consumed and +selection never starts. + +**The real fix** (not built, tracked): a first-party selection layer — hit-test to a cell, own the +selection anchors, read the text straight out of `TerminalBuffer`, and write it with `ClipboardManager`. +Paste-in is unaffected and already works. + +- [ ] Confirm on a device that long press is inert and does **not** show a broken Copy affordance. + +--- + +## A34 — instrumented E2E vs a real `npm start` host + +Infrastructure now EXISTS (`:app` has androidTest deps, a Hilt-aware `testInstrumentationRunner`, and +Espresso + Compose-UI-test on the classpath) but **the tests themselves are not written**. See +`android/README.md` → "Instrumented tests" for the one runner class that must be authored first. -## 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. @@ -22,15 +136,19 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h - [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS. ## A35 — one macrobenchmark / Espresso happy path + +The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumenting `:app`'s +`benchmark` variant out-of-process); it has **no sources yet**. + - [ ] pair → attach → type → approve, on a real device. +- [ ] cold-start timing on real hardware (`StartupTimingMetric`). The 1085 ms above is a software-GPU + emulator number and must not be quoted as a baseline. ## 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). +- [ ] IME/CJK composition; http/https link tap. (Selection→clipboard is the gap above, not a test.) +- [ ] **real font-metric → grid resize** and 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. @@ -47,22 +165,27 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h - [ ] **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. +- [ ] token registers to every paired host + self-heals on rotation / new host / removal. +- [ ] **In a MINIFIED build specifically** — FCM and ML Kit both discover components reflectively, so + re-verify push registration and QR scanning on the `benchmark`/release variant, not just debug. ## Pairing / cert / storage (A19/A27/A11/A12) -- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. +- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. **Run this on a minified + variant too** (see the `ComponentRegistrar` note above — this path is the one R8 already broke once). - [ ] §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. +- [ ] **Cert summary does not crash** — see the `javax.naming` defect under "Known gaps"; the issuer-CN / + expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY. - [ ] 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. + right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and + `assetlinks.json`.) +- [ ] continue-last-session banner re-opens. (The no-host → pairing half is observed above.) - [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` + `ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600). @@ -85,11 +208,32 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ; **push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited). -## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md) +--- + +## Known gaps (tracked, non-blocking for QA but must be routed to an owner) + +- [ ] **`:client-tls` uses `javax.naming.ldap.LdapName`, which does not exist on Android.** Found by R8 + (`Missing class javax.naming.ldap.LdapName ... referenced from CertificateSummaryReader.commonName`). + On-device that call throws `NoClassDefFoundError`, an **`Error`** — so it slips straight through the + function's `catch (_: Exception)` guard and propagates out of `summarize()`. The module is pure-JVM + and its unit tests run on a JDK where `LdapName` DOES exist, which is exactly why the green suite + never caught it. Effect: the device-cert screen's issuer-CN/expiry summary (A27) crashes. + Fix: hand-parse the RFC2253 DN (or at minimum catch `Throwable`). The `-dontwarn` in + `proguard-rules.pro` only unblocks R8; it changes nothing at runtime. +- [ ] **The auth cookie jar + cipher are not installed in `:app`'s DI**, so the `WEBTERM_TOKEN` gate is + inert at runtime even though every piece is implemented and tested. - [ ] 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. +- [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a + `dataExtractionRules` `` xml resource. Until then a D2D migration could copy the + sealed auth cookie to another handset. +- [ ] AGP's lint crashes with `FirDeclaration was not found for class KtProperty, fir is null` on + `ThumbnailPipeline.kt` when run under `--rerun-tasks`. `lintVitalRelease` passes normally and from + a cold lint state; this is a lint/K2 internal bug (lint says so itself), not an app defect. Do not + use `--rerun-tasks` in a release gate. + +> RESOLVED since the last revision: the A21 reflection shim (`getMethod("getTerminalView")`) is **gone** — +> `RemoteTerminalHostView` now holds the stock `com.termux.view.TerminalView` directly, so there is no +> reflective getter left to break under R8 and no keep rule is needed for one. diff --git a/android/PROGRESS_ANDROID.md b/android/PROGRESS_ANDROID.md index 4a3a23c..9914597 100644 --- a/android/PROGRESS_ANDROID.md +++ b/android/PROGRESS_ANDROID.md @@ -372,6 +372,99 @@ warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid --- +## ⚠️ CORRECTION (2026-07-30): "COMPLETE" below meant COMPILES, not WORKS + +The section that follows claimed all 36 tasks had landed. That was true at the level it was measured — +the modules built, the APK assembled and ~484 JVM tests were green — but NOTHING had ever run on an +Android device or emulator, and the first thing that would have happened on one is a crash. An audit on +2026-07-30 found, and a repair pass fixed, the following. Read this before trusting anything below. + +**Three defects made the app unusable on real hardware.** Each was invisible to the JVM suite for the +same structural reason: no JVM test instantiates an Android `View`. + +1. **Every key press crashed.** `RemoteTerminalView` bound the forked emulator but never installed a + `TerminalViewClient`. The pinned Termux `TerminalView` dereferences `mClient` with no null guard on + the first line of the paths a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection` + offset 0, `onKeyUp`, `onKeyPreIme`). The KDoc claimed "A17 sets it" — A17 never did; only the + `HardwareKeyRouter` and the KeyBar shipped, and the KeyBar works precisely because it bypasses the + view. Installing a client is not sufficient: returning false continues into `mTermSession.write()` + at offset 150, and `mTermSession` is null forever (`TerminalSession` is final and forking a process + is exactly what this app must not do, §6.1). Fixed by a client that consumes the key itself, with + `KeyRouting` deliberately having NO defer-to-stock branch, so the crash is unrepresentable rather + than merely avoided. `KeyHandler` remains the authority for the key tables, so DECCKM still emits + `ESC O A`. +2. **No `resize` was ever sent.** `RemoteTerminalSession.updateSize` had zero call sites and the stock + fallback is dead (`TerminalView.updateSize` early-returns without a session), so the PTY stayed at + the server's 80x24 and every full-screen TUI rendered into the wrong box. `TerminalResizeDriver` + now drives it from real cell metrics, read via the PUBLIC `TerminalRenderer.getFontWidth()` / + `getFontLineSpacing()` accessors — no reflection (silently breaks under R8) and no re-measured + `Paint` (drifts from what the renderer actually draws, the §R5 risk). +3. **Bare-LAN connections were impossible.** `network_security_config.xml` was a self-labelled STUB + denying all cleartext, while `HostEndpoint` accepts `http` and derives `ws://`. Its own comment + promised a CIDR allowlist the platform cannot express. See plan §6.9 for the corrected posture. + +**Adversarial review then found three more, and got one wrong:** + + - **Swipe-scrolling inside an alternate-screen TUI still crashed.** The touch path bypasses + `TerminalViewClient` entirely: `doScroll` converts a scroll to `handleKeyCode` whenever the + alternate buffer is active, which dereferences `mTermSession`. Claude Code IS an alternate-screen + TUI, so this was a crash during ordinary use on the app's main screen. `TerminalScrollGesture` now + claims the vertical drag first and reproduces all three `doScroll` branches, closing the fling + runnable and `onGenericMotionEvent` with it. + - **`autofill()` dereferences `mTermSession` unguarded** while the view advertises itself + autofillable, so any password manager crashed the app. The subtree is excluded from the autofill + structure. + - The review also demanded stock text selection be RESTORED after the focus rework made it + unreachable. It cannot be: the ActionMode's own Copy and Paste handlers dereference the absent + session (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136), so a + reachable selection UI has an NPE on its Copy button. **Long press stays consumed and copy-out is + a recorded FEATURE GAP** — a first-party selection layer over `TerminalBuffer` + `ClipboardManager` + is the real fix. This is the one place the reviewer was wrong and the builder right. + +**Silently-lost server data, now decoded:** the W2 `queue` frame had no `ServerMessageType` member so +every one was dropped as undecodable (the "N queued" badge could never appear), and W1 +`status.preview` was not modelled at all, which made remote approvals **BLIND** — the user was asked to +approve a `Bash` call without seeing the command. Both now decode, with the rule that a broken preview +never costs the frame (`pending` is what makes the gate appear). + +**Dead code that shipped as features:** `ThumbnailPipeline` (session-list previews), `QuickReply` +(chips + palette) and `PointerContextMenu` were fully implemented and unit-tested with ZERO production +call sites. All three are now wired. `ThumbnailPipeline`'s formal cross-review — explicitly skipped at +the time, as the AW3 entry below admits — has finally been run. + +**Credential handling:** the `WEBTERM_TOKEN` cookie is now carried on REST and the WS upgrade, sealed +with Tink AEAD under an AndroidKeyStore key, **fail-closed** (a seal failure persists nothing rather +than falling back to plaintext), and `allowBackup=false` keeps a 30-day shell credential out of Google +Drive and D2D transfer. + +**RESOLVED LATER THE SAME DAY** (this list was accurate when written; it is not any more): + - **A34 / A35 now exist and RUN.** `:app/src/androidTest/**` holds 67 instrumented tests — the four + crash regressions, the A34 E2E set (attach→output, ring-buffer replay, spawn failure via a second + deliberately-broken server, a gate resolved through `POST /hook/decision`, and the F9 bad-Origin + rejection), and the Compose UI tests plan §7 named — plus `macrobenchmark/` for A35. All 67 execute + green on `emulator-5554` against this repo's own server. Reaching the host needs + `ALLOWED_ORIGINS=http://10.0.2.2:` (the server derives allowed origins from NIC IPs, and + 10.0.2.2 is the emulator's alias for the host, not an interface address). + - **Device QA is no longer ~0%.** The blocker fixes are device-verified, not just JVM-verified. + - Release signing, versioning and minification are done: `assembleRelease` runs R8 and now REFUSES to + emit an unsigned artifact instead of quietly producing one. + - **Copy-out is done** — a first-party selection layer over `TerminalBuffer.getSelectedText` + + `ClipboardManager`, since the stock ActionMode's Copy handler dereferences the absent session. + - The six audit leftovers are closed too: silent certificate rotation (+ `/device/:id/recover`), the W2 + inject-queue routes, unread-watermark persistence, host-removal UI, and `/config/ui` finally honoured + (fail-closed). + +**Still genuinely open:** + - **S2** — the FCM real-handset delivery spike. Needs ≥2 physical devices under Doze/OEM battery + managers; an emulator cannot answer it. + - One instrumented test (`wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch`) was seen to kill the + test process when the suite was driven WITHOUT the host arguments on a heavily-recycled emulator. It + passes reproducibly under the documented invocation on a clean install. Re-check on real hardware + before trusting it either way. + - Everything else device-observable in `DEVICE_QA_CHECKLIST.md` that no automated test covers. + +--- + ## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1–A36 + S1) landed Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure diff --git a/android/README.md b/android/README.md index 0a0a431..67e1db1 100644 --- a/android/README.md +++ b/android/README.md @@ -9,6 +9,13 @@ This directory is a **Gradle multi-module** project. The module set mirrors the SPM package set and inherits its rule: *dependencies only flow down; nothing points upward* (ARCHITECTURE §1). +> **State, honestly:** the app builds, minifies, signs (given a keystore) and cold-starts +> to the pairing screen on an emulator. It has **never talked to a real WebTerm host from +> a device**, and almost nothing in +> [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md) is ticked. Version is +> `0.1.0-alpha01` for exactly that reason. Read that checklist before calling anything +> here done. + ## Build environment (SDK installed — all modules build) The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework @@ -19,10 +26,12 @@ against SDK 35/36. `:client-tls`, `:test-support`, `:transport-okhttp`. - **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):** `:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`. +- **Instrumented-only:** `:macrobenchmark` (`com.android.test`; assembles here, runs only + on a device/emulator). Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`; `google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate: -`./gradlew test :app:assembleDebug koverVerify`. +`./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest koverVerify`. ## Module map (mirror of the iOS SPM packages — plan §3) @@ -37,10 +46,15 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools | HostRegistry | `:host-registry` | Android (DataStore) | ✅ built | | SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built | | App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built | +| — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources | -> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport` -> impls, JVM) is owned by task **A7** and will be added then. The iOS -> `URLSession*Transport`s consolidate into it (plan §3 framing note). +`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a +`com.android.test` module — a **separate APK** that drives `:app` out of process via +UiAutomator, which is the only way startup/frame timings are real. It therefore cannot +violate "dependencies only flow down": nothing depends on it, and it reaches `:app` +through `targetProjectPath`, not a `project()` dependency. It instruments `:app`'s +`benchmark` variant (see "Build types" below). The benchmark sources themselves are not +written yet. ### Dependency graph (arrows = "depends on") @@ -82,15 +96,124 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`). ```bash # Use the committed wrapper for everything. -./gradlew help # sanity: the build configures -./gradlew projects # lists the 5 pure modules -./gradlew build # compile all pure modules -./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK) +./gradlew help # sanity: the build configures +./gradlew projects # lists every module +./gradlew test # JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK) +./gradlew :app:assembleDebug # the installable debug APK +./gradlew koverVerify # the ≥80% gate on the pure modules + +# Release path (needs a keystore — see "Release signing") +./gradlew :app:assembleRelease # R8 + resource shrinking + signing +./gradlew :app:lintVitalRelease # the release-blocking lint subset; must be clean + +# Minified build WITHOUT a keystore — the practical way to test R8 keep rules +./gradlew :app:assembleBenchmark # same shrinking as release, debug-signed → installable ``` > Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`, > `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data, > small focused files — same discipline as the rest of the repo. +> +> **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug +> (`FirDeclaration was not found for class KtProperty, fir is null`, on +> `ThumbnailPipeline.kt`). `lintVitalRelease` passes normally and from a cold lint state. + +## Build types + +| Type | Minified | Shrunk res | Debuggable | Signed with | Purpose | +|---|---|---|---|---|---| +| `debug` | no | no | yes | debug key | development; stable applicationId for deep-link tests (A32) | +| `release` | **yes** | **yes** | no | **release key (required)** | the shipping artifact | +| `benchmark` | **yes** | **yes** | no | debug key | `initWith(release)` + `isProfileable`; what `:macrobenchmark` measures, and the only way to exercise R8 without a keystore | + +`benchmark` exists because a benchmark must measure the code that actually ships. It sets +`isProfileable = true`, which makes AGP inject `` into +the merged manifest — done there rather than in `AndroidManifest.xml` so the shipping +manifest carries no benchmark-only tag. + +## Versioning + +Current: `versionCode = 1`, `versionName = "0.1.0-alpha01"`. + +- **`versionCode`** is a plain monotonic counter — **+1 for every artifact handed to anyone** + (a Play track, an APK sent to a tester, an archived benchmark build). It is deliberately + NOT derived from `versionName`; keeping them independent is what lets a hotfix ship + without renumbering. It is still `1` because no artifact has ever left the build machine; + the first distributed build takes `2`. +- **`versionName`** is `-alphaNN`. The client tracks the server's `0.1.x` line + (root `package.json` is `0.1.0`). The `-alpha01` suffix is a factual claim about device + verification, not marketing: + - `-alphaNN` — builds, minifies, JVM-tested; **device QA essentially unstarted**. ← today + - `-betaNN` — the A34/A35 blocks of `DEVICE_QA_CHECKLIST.md` pass on real hardware. + - `0.1.0` — the whole checklist is ticked. + + Bump the suffix on any user-visible change while still in alpha; move to `0.1.1-alphaNN` + only when the server line moves. + +## Release signing + +There is **no keystore in this repository and there must never be one.** Credentials are +read at configuration time from the first of these that has them: + +1. `android/keystore.properties` — the conventional path. Copy + [`keystore.properties.example`](keystore.properties.example). + ⚠️ **Confirm `keystore.properties` is listed in `android/.gitignore` before creating it.** + The existing rules cover `*.jks` / `*.keystore` / `*.p12` but not this filename. +2. `android/local.properties` — already gitignored, so it needs no new rule. Same four keys. +3. `WEBTERM_RELEASE_STORE_FILE` / `_STORE_PASSWORD` / `_KEY_ALIAS` / `_KEY_PASSWORD` — for CI. + +Keys: `webterm.release.storeFile` (absolute, or relative to `android/`), +`.storePassword`, `.keyAlias`, `.keyPassword`. + +**Degradation contract:** with no credentials, `debug`, `benchmark`, `assembleDebugAndroidTest` +and every test still work, and `:app:assembleRelease` **fails at `packageRelease`** with +instructions. It fails at *packaging*, deliberately after R8, so an unconfigured machine +still gets a full, verifiable R8 run. It never silently emits `app-release-unsigned.apk` +(which is exactly what it used to do). + +**Archive `app/build/outputs/mapping/release/mapping.txt` with every distributed artifact** — +release builds keep line numbers but obfuscate names, so without the mapping file a crash +report cannot be retraced. + +### R8 / keep rules + +`app/proguard-rules.pro` is deliberately short and every rule is justified in place; most +of the stack (kotlinx.serialization, Hilt, Tink, Firebase, OkHttp, Compose, CameraX) +ships its own consumer rules and must not be re-declared. Two rules are load-bearing and +were both derived from **observed** failures, not guesswork: + +- `com.termux.**` is kept whole, because `:terminal-view` binds to non-API internals of the + pinned v0.118.0 (the public `TerminalView.mEmulator` field, `TerminalRenderer.getFontWidth()`) + and the JitPack artifacts ship no consumer rules. +- `implements com.google.firebase.components.ComponentRegistrar { (); }`, because + without it R8 strips the reflectively-invoked constructors of ML Kit's registrars and + **QR scanning silently stops working in release builds only**. + +Before adding a rule, read the merged configuration R8 actually used: +`app/build/outputs/mapping//configuration.txt`. To validate rules, install the +`benchmark` APK and exercise the feature — a wrong keep rule is invisible in debug. + +## Instrumented tests + +`:app` has an androidTest classpath (androidx.test + Espresso + Compose UI test + +`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to +`wang.yaojia.webterm.HiltTestRunner`. + +⚠️ **That runner class does not exist yet** — it is the first file the A34 author must write, +into `app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt`: + +```kotlin +class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application = + super.newApplication(cl, HiltTestApplication::class.java.name, context) +} +``` + +A custom runner is **required**, not stylistic: `newApplication` is the only hook that can +replace the app-under-test's `Application` with the generated `HiltTestApplication`. Setting +`android:name` in the androidTest manifest cannot do it — that manifest is merged into the +*test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the +class (the runner name is just a manifest value); an actual instrumentation *run* needs it. ## Android SDK setup (proven working) @@ -127,5 +250,32 @@ AGP library module compiled against SDK 35 and produced an AAR): `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): +- **A `com.android.test` module cannot use a versioned plugin alias.** The root build + script already puts AGP on the classpath via the `android.library`/`android.application` + aliases, so a third versioned request for the same artifact fails with *"plugin is already + on the classpath with an unknown version"*. `:macrobenchmark` therefore applies + `id("com.android.test")` bare — same as `:terminal-view` with `com.android.library`. The + version is still pinned once, as `agp` in the catalog. + +## Emulator (installed) + +An AVD named **`webterm`** exists (android-35, arm64, software GPU) and is what produced the +on-device evidence recorded in [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md): + +```bash +export ANDROID_HOME=/usr/local/share/android-commandlinetools +$ANDROID_HOME/emulator/emulator -avd webterm -gpu swiftshader_indirect & +$ANDROID_HOME/platform-tools/adb devices -l + +# validate the MINIFIED build (this is what catches bad keep rules) +./gradlew :app:assembleBenchmark +adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk +adb logcat -c && adb shell am start -W -n wang.yaojia.webterm/.MainActivity +adb logcat -d --pid=$(adb shell pidof wang.yaojia.webterm) | grep -E "FATAL|NoSuchMethod|NoClassDefFound" +``` + +Note it is a **software-GPU** emulator: it is fine for crash/keep-rule/route validation and +useless for performance numbers. Real macrobenchmark figures need physical hardware. + +To add more SDK pieces later: `sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`. diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt index 00ea5eb..f5de8e2 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt @@ -15,8 +15,11 @@ import wang.yaojia.webterm.wire.HttpTransport * `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain, * deviceName, attestation? }` → 201 `{ deviceId, cert, caChain, * notBefore, notAfter, renewAfter }` - * `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The - * server schema is `.strict()`; NO keyAlg/subdomain/deviceName. + * `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 `{ cert, caChain, + * notAfter }`. The server schema is `.strict()`; NO + * keyAlg/subdomain/deviceName. + * `POST /device/:id/recover` [NO client cert — plain HTTPS] `{ cert, csr }` → 201 `{ cert, + * caChain, notAfter }`. The EXPIRED leaf's escape hatch. * * Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is * sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs @@ -29,8 +32,12 @@ public class DeviceEnrollmentClient( baseUrl: String, private val http: HttpTransport, ) { - /** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */ - private val base: String = baseUrl.trim().trimEnd('/') + /** + * Base control-plane URL with any trailing slash removed, so `baseUrl + path` is well-formed. + * Readable so a rotation driver can persist WHICH control plane a device enrolled with and renew + * against that same one (a URL is not a credential). + */ + public val baseUrl: String = baseUrl.trim().trimEnd('/') /** * One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is @@ -98,7 +105,38 @@ public class DeviceEnrollmentClient( ) val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew" val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken)) - return toResult(decodeOn201(response, EnrollResponseDto.serializer())) + // The renew 201 is `{ cert, caChain, notAfter }` — it does NOT echo deviceId, so the id we + // addressed the request to is the authoritative one. + return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId) + } + + /** + * Recover an EXPIRED leaf: `POST /device/:id/recover` carrying the lapsed [expiredCertDer] in the + * BODY plus a fresh [csrDer] over the SAME hardware key. + * + * **This call must NOT ride an mTLS transport.** `/renew` is authenticated by the very leaf it + * renews, so a lapsed leaf cannot renew itself — and no TLS terminator will even forward an expired + * client certificate (nginx answers a bare 400 under `ssl_verify_client optional`; `optional_no_ca` + * tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). The caller therefore supplies a + * PLAIN [HttpTransport] with no client identity; possession is proven by the CSR self-signature, + * which the control plane checks together with `CSR key == registered key`. + * + * No bearer is accepted on this path at all — the body is the whole credential story. Everything + * else the server enforces is unchanged: real X.509 path validation to the device-CA, SPIFFE parse, + * `notBefore` (never graced), `:id` must match the cert, and revocation still applies. Only + * `notAfter` is graced (30 days), so a leaf lapsed beyond that answers 401. + */ + public suspend fun recover(deviceId: String, expiredCertDer: ByteArray, csrDer: ByteArray): EnrollmentResult { + if (deviceId.isEmpty() || expiredCertDer.isEmpty() || csrDer.isEmpty()) { + throw DeviceEnrollmentError.InvalidRequest + } + val body = EnrollJson.encodeToString( + RecoverRequestBody.serializer(), + RecoverRequestBody(cert = base64(expiredCertDer), csr = base64(csrDer)), + ) + val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/recover" + val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearer = null)) + return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId) } // ── Request/response plumbing ──────────────────────────────────────────────────────────── @@ -114,7 +152,7 @@ public class DeviceEnrollmentClient( // Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty // string must never emit a bare "Bearer " header. if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer" - return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody) + return HttpRequest(method = method, url = baseUrl + path, headers = headers, body = jsonBody) } /** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error` @@ -127,6 +165,24 @@ public class DeviceEnrollmentClient( .getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse } + /** + * Map a re-issuance (renew / recover) 201, whose body carries no `deviceId`, using the [deviceId] + * the request was addressed to. `renewAfter` is normally absent here — the rotation policy + * re-derives it from the leaf's own validity window (`RotationPolicy.renewAfterFor`). + */ + private fun toReissueResult(dto: RenewResponseDto, deviceId: String): EnrollmentResult = + EnrollmentResult( + deviceId = deviceId, + certificate = decodeDerOrThrow(dto.cert), + caChain = dto.caChain.map { decodeDerOrThrow(it) }, + notBefore = parseInstantOrNull(dto.notBefore), + notAfter = parseInstantOrNull(dto.notAfter), + renewAfter = parseInstantOrNull(dto.renewAfter), + ) + + private fun decodeDerOrThrow(base64Der: String): ByteArray = + decodeBase64OrNull(base64Der) ?: throw DeviceEnrollmentError.MalformedResponse + private fun toResult(dto: EnrollResponseDto): EnrollmentResult { val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse val chain = dto.caChain.map { entry -> diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt index 27148a8..6f1afc3 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt @@ -97,6 +97,19 @@ internal data class EnrollRequestBody( @Serializable internal data class RenewRequestBody(val csr: String) +/** + * The `/device/:id/recover` request body — the EXPIRED leaf plus a fresh CSR over the same key. + * + * The lapsed certificate travels in the BODY (not as an mTLS client cert) because no TLS terminator + * forwards an expired client certificate: nginx answers a bare 400 under `ssl_verify_client optional`, + * and `optional_no_ca` tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`. Nothing is + * lost: the CSR is self-signed by the same private key and the control-plane signer enforces CSR + * proof-of-possession plus `CSR key == registered key`, so possession is proven exactly as the + * handshake used to prove it. The server schema is `.strict()` on these two keys. + */ +@Serializable +internal data class RecoverRequestBody(val cert: String, val csr: String) + @Serializable internal data class LoginResponseDto( val enrollToken: String, @@ -114,6 +127,25 @@ internal data class EnrollResponseDto( val renewAfter: String? = null, ) +/** + * The `/device/:id/renew` and `/device/:id/recover` 201 body — `{ cert, caChain, notAfter }` ONLY. + * + * Deliberately NOT [EnrollResponseDto]: the re-issuance routes do not echo `deviceId` (nor + * `notBefore`/`renewAfter`) — only `/device/enroll` does (`control-plane/src/api/renew.ts` vs + * `device-enroll.ts`). Decoding a renew 201 against the enroll DTO, whose `deviceId` is required, + * failed EVERY silent rotation with `MalformedResponse`. The device id is supplied by the caller (it + * is the path segment the request was addressed to, which is the authoritative value anyway), and the + * absent `renewAfter` is re-derived from the leaf by `RotationPolicy.renewAfterFor`. + */ +@Serializable +internal data class RenewResponseDto( + val cert: String, + val caChain: List = emptyList(), + val notBefore: String? = null, + val notAfter: String? = null, + val renewAfter: String? = null, +) + @Serializable internal data class ErrorDto(val error: String? = null) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicy.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicy.kt new file mode 100644 index 0000000..3168b13 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicy.kt @@ -0,0 +1,150 @@ +package wang.yaojia.webterm.api.enroll + +import java.time.Duration +import java.time.Instant + +/** + * Rotation timing of the installed device identity — the input to [RotationPolicy]. Android analogue + * of iOS `DeviceRenewalState`. + * + * [notAfter] is the leaf's HARD expiry (past it the TLS stack rejects the cert outright) and + * [renewAfter] is the advisory "renew from the same key now" instant. Only non-secret timing plus the + * non-secret [deviceId] (which is a URL path segment) surface here: the private key never leaves + * AndroidKeyStore and the cert bytes are never carried through this type, so it is safe to log. + */ +public data class DeviceRenewalState( + /** The enrolled device id — the `:id` in `POST /device/:id/renew` and `/device/:id/recover`. */ + val deviceId: String, + val notAfter: Instant?, + val renewAfter: Instant?, +) { + /** + * Is the leaf due for renewal as of [now]? A missing [renewAfter] NEVER triggers (fail-safe — the + * TLS stack is the real gate; the scheduler only pre-empts expiry). Mirrors + * [EnrollmentResult.isRenewalDue] and iOS `DeviceRenewalState.isRenewalDue`. + */ + public fun isRenewalDue(now: Instant): Boolean { + val due = renewAfter ?: return false + return !now.isBefore(due) // now >= renewAfter + } +} + +/** What a scheduler must do for one rotation pass — the total answer of [RotationPolicy.decide]. */ +public enum class RotationDecision { + /** Nothing to do: the renew window has not opened (the cheap, common case). */ + NOT_DUE, + + /** An attempt IS due but the previous one failed too recently — wait, do not hammer. */ + BACKING_OFF, + + /** Still valid: re-CSR from the same key and renew over mTLS (`POST /device/:id/renew`). */ + RENEW, + + /** + * EXPIRED but inside the recovery grace: the leaf can no longer authenticate its own re-issuance, + * so recover over PLAIN HTTPS with the lapsed cert in the body (`POST /device/:id/recover`). + */ + RECOVER, + + /** TERMINAL — expired past the grace window. No request can succeed; only a fresh enroll can. */ + RE_ENROLL_REQUIRED, +} + +/** + * The pure, JVM-testable rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`, + * `TerminalGridMath`). Given "now", the leaf's timing and the last failed attempt it answers + * renew / recover / wait / re-enroll — no clock, no keystore, no network, so every branch is a unit + * test rather than a device observation. + * + * Ported from iOS `CertificateRotationScheduler`'s timing rules, plus the two branches the phone + * track needs and iOS lacks (it can only renew, so an iOS device whose leaf lapses is stranded): + * - [RotationDecision.RECOVER] — the `/device/:id/recover` escape hatch, because no TLS terminator + * forwards an expired client certificate, so a lapsed leaf can never authenticate its own renewal. + * - [RotationDecision.RE_ENROLL_REQUIRED] — reported ONCE as terminal instead of retrying forever + * (the host-track agent logged the same failure 6380 times before this rule existed). + */ +public object RotationPolicy { + /** + * The control plane issues `renewAfter = notBefore + 2/3 · lifetime` + * (`control-plane/src/api/device-enroll.ts` `DEFAULT_RENEW_FRACTION`). The client mirrors the + * fraction as exact integer duration math so [renewAfterFor] lands on the same instant. + */ + private const val RENEW_FRACTION_NUMERATOR: Long = 2L + private const val RENEW_FRACTION_DENOMINATOR: Long = 3L + + /** + * How long after `notAfter` a lapsed leaf may still be swapped via `/device/:id/recover` + * (30 days) — the same window the control plane's `DEFAULT_EXPIRED_RENEW_GRACE_MS` allows. Asking + * outside it is pointless: the server answers 401. + */ + public val DEFAULT_EXPIRED_RECOVERY_GRACE: Duration = Duration.ofDays(30) + + /** + * Minimum gap after a FAILED attempt before another is made. A pass runs on every app + * foreground, and the control plane rate-limits renewals to 30/hour per identity, so an + * un-throttled retry converts one transient failure into a 429 storm. + */ + public val DEFAULT_FAILURE_BACKOFF: Duration = Duration.ofMinutes(15) + + /** + * Derive the advisory renew instant from the leaf's own validity window. + * + * This derivation is LOAD-BEARING, not a convenience: `POST /device/:id/renew` and + * `POST /device/:id/recover` answer `{cert, caChain, notAfter}` only — neither returns + * `renewAfter` (only `/device/enroll` does). A client that persisted the enroll-time value and + * never recomputed it would rotate exactly once and then report "not due" until the cert died. + * + * Returns null when either bound is unknown or the window is not a lifetime (notAfter <= + * notBefore) — fail-safe, because [decide] treats a null [DeviceRenewalState.renewAfter] as + * never-due rather than inventing a past instant that would renew-storm. + */ + public fun renewAfterFor(notBefore: Instant?, notAfter: Instant?): Instant? { + if (notBefore == null || notAfter == null) return null + if (!notBefore.isBefore(notAfter)) return null + val lifetime = Duration.between(notBefore, notAfter) + return notBefore.plus( + lifetime.multipliedBy(RENEW_FRACTION_NUMERATOR).dividedBy(RENEW_FRACTION_DENOMINATOR), + ) + } + + /** + * The whole decision table, in priority order: + * 1. expired past [expiredRecoveryGrace] → [RotationDecision.RE_ENROLL_REQUIRED] (terminal; it + * outranks the backoff because no amount of waiting can help), + * 2. otherwise pick the desired action — expired → RECOVER, [DeviceRenewalState.isRenewalDue] → + * RENEW, else NOT_DUE, + * 3. an idle leaf stays [RotationDecision.NOT_DUE] (there is no attempt to throttle), + * 4. a desired action within [failureBackoff] of [lastFailureAt] → [RotationDecision.BACKING_OFF]. + * + * @param lastFailureAt when the last attempt FAILED (null = none, or the last one succeeded). + */ + public fun decide( + state: DeviceRenewalState, + now: Instant, + lastFailureAt: Instant? = null, + expiredRecoveryGrace: Duration = DEFAULT_EXPIRED_RECOVERY_GRACE, + failureBackoff: Duration = DEFAULT_FAILURE_BACKOFF, + ): RotationDecision { + require(!expiredRecoveryGrace.isNegative) { "expiredRecoveryGrace must not be negative" } + require(!failureBackoff.isNegative) { "failureBackoff must not be negative" } + + val notAfter = state.notAfter + // Terminal first: past `notAfter + grace` nothing can succeed. A non-negative grace makes this + // strictly stronger than "expired", so it needs no separate expiry guard. + if (notAfter != null && now.isAfter(notAfter.plus(expiredRecoveryGrace))) { + return RotationDecision.RE_ENROLL_REQUIRED + } + val isExpired = notAfter != null && now.isAfter(notAfter) + + val desired = when { + isExpired -> RotationDecision.RECOVER + state.isRenewalDue(now) -> RotationDecision.RENEW + else -> RotationDecision.NOT_DUE + } + if (desired == RotationDecision.NOT_DUE) return RotationDecision.NOT_DUE + if (lastFailureAt != null && now.isBefore(lastFailureAt.plus(failureBackoff))) { + return RotationDecision.BACKING_OFF + } + return desired + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt index 5a17990..f1dbb84 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt @@ -14,6 +14,15 @@ public data class CommitLogEntry( val hash: String, val at: Long, val subject: String = "", + /** + * w6/G4: reachable from HEAD but NOT from `@{u}` — i.e. this commit has not been pushed. + * + * Server-computed from `rev-list`, and deliberately NOT "the first N rows": `git log` is + * date-ordered, so merging an older branch interleaves unpushed commits BELOW pushed ones. The + * row-count shortcut fails in the dangerous direction — it would call an unpushed commit pushed. + * Absent ⇒ unknown (no upstream to compare against), which must not render as "pushed". + */ + val unpushed: Boolean? = null, ) /** @@ -25,7 +34,29 @@ public data class GitLogResult( @Serializable(with = CommitLogEntryListSerializer::class) val commits: List = emptyList(), val truncated: Boolean = false, -) + /** + * w6/G4: upstream short name used to label the pushed/unpushed boundary. Null ⇒ nothing to compare + * against, so **no boundary may be drawn at all** — not a boundary drawn at position zero. + */ + val upstream: String? = null, +) { + /** + * Index of the LAST unpushed commit, or null when no boundary may be drawn. + * + * The boundary is drawn ONCE, after this index — never once per unpushed commit, and never at all + * when [upstream] is absent. Returns null rather than -1 so a caller cannot accidentally treat + * "no boundary" as "boundary before everything". + */ + public val upstreamBoundaryIndex: Int? + get() { + if (upstream == null) return null + val last = commits.indexOfLast { it.unpushed == true } + return last.takeIf { it >= 0 } + } + + /** How many commits are waiting to be pushed; 0 when unknown or nothing is pending. */ + public val unpushedCount: Int get() = commits.count { it.unpushed == true } +} /** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */ internal object CommitLogEntryListSerializer : diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt index e543ea1..11e6234 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt @@ -42,6 +42,16 @@ public data class CommitResult(val commit: String = "") @Serializable public data class PushResult(val branch: String? = null, val remote: String? = null) +/** + * `POST /projects/git/fetch` 200 payload (w6/G2). + * + * [lastFetchMs] is FETCH_HEAD's mtime AFTER the fetch, so the UI can stop saying "stale" without + * re-probing. On FAILURE the server deliberately leaves the old value alone, which is why this is + * nullable: a fetch that did not happen must keep reading as stale rather than pretending it refreshed. + */ +@Serializable +public data class FetchResult(val lastFetchMs: Long? = null) + /** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */ @Serializable public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt index f25e0e6..ff06bb4 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt @@ -42,4 +42,9 @@ public data class LiveSessionInfo( /** Server ms timestamp of the last PTY output (== createdAt until first output). Additive * optional field — pre-P1 servers omit it; `null` means "no unread data source". */ val lastOutputAt: Long? = null, + /** W2 pending prompt-queue depth (`src/types.ts` `queueLength?`) — what the "N queued" badge + * renders. Additive OPTIONAL: `null` on a pre-W2 server (queue unsupported / unknown), `0` when + * the queue is empty; the badge shows only for a positive value. Decoded tolerantly + * ([LossyIntSerializer]) so a garbled value can never hide a running session. */ + @Serializable(with = LossyIntSerializer::class) val queueLength: Int? = null, ) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt index a96395d..642fdaf 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt @@ -17,6 +17,12 @@ public data class ProjectSessionRef( val clientCount: Int, val createdAt: Long, val exited: Boolean, + /** + * w6/G7: the session's working directory, needed to attribute it to a worktree. Matching is + * DEEPEST-first, because `.claude/worktrees/` lives INSIDE the main checkout and a prefix + * match would count every worktree session against the parent repo too. + */ + val cwd: String? = null, ) /** @@ -40,6 +46,8 @@ public data class ProjectInfo( val behind: Int? = null, /** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */ val lastCommitMs: Long? = null, + /** w6/G1: porcelain line count, gated the same way as [dirty]. */ + val dirtyCount: Int? = null, @Serializable(with = ProjectSessionRefListSerializer::class) val sessions: List = emptyList(), ) @@ -65,6 +73,10 @@ public data class ProjectDetail( val isGit: Boolean, val branch: String? = null, val dirty: Boolean? = null, + /** w6/G1: porcelain line count, gated the same way as [dirty]. */ + val dirtyCount: Int? = null, + /** w6/G1: git repos only; null for a non-git dir. See [SyncState] for what may be trusted. */ + val sync: SyncState? = null, @Serializable(with = WorktreeInfoListSerializer::class) val worktrees: List = emptyList(), @Serializable(with = ProjectSessionRefListSerializer::class) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PromptQueue.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PromptQueue.kt new file mode 100644 index 0000000..f689994 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PromptQueue.kt @@ -0,0 +1,77 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.Serializable + +/** + * W2 prompt queue — the server-side FIFO of follow-up prompts injected into a session's PTY the next + * time Claude goes idle (`src/server.ts` ~605/644/654, `SessionManager.enqueueFollowup`/`clearQueue`). + * + * A queued entry is **raw keyboard input for a shell** (invariant #9 / byte-shuttle): it is stored and + * later written to the PTY verbatim. Nothing on this path may trim, escape, re-encode or normalise it — + * the only transformation is the server appending `\r` when the caller passed `appendEnter = true`. + */ + +/** + * `GET /live-sessions/:id/queue` 200 → `{ length, items }` — the pending depth plus the queued texts + * (verbatim keystroke bytes). Both fields default, so a missing/garbled key degrades to an empty queue + * instead of throwing; a non-object body yields `null` from `LossyDecode` (→ `InvalidResponseBody`). + */ +@Serializable +public data class QueueSnapshot( + /** Pending entries on the server. Equals `items.size` for a healthy server; trust [items] for + * rendering and [length] only as the badge number the server itself broadcasts. */ + val length: Int = 0, + /** The queued prompts in FIFO order, verbatim. */ + val items: List = emptyList(), +) + +/** + * Outcome of the two GUARDED queue writes — `POST` (enqueue) and `DELETE` (cancel-all). One union for + * both ops (the `GitWriteOutcome` precedent: one union, six routes); [Full], [TooLarge] and [Disabled] + * are simply unreachable for the DELETE, whose handler neither validates text nor consults the + * `QUEUE_ENABLED` kill-switch. + * + * Every variant maps to different UI behaviour, which is why they are typed rather than folded into a + * status code: + * - [Ok] — the server accepted it; [length] is the authoritative new depth for the "N queued" badge. + * - [Full] — 409, the bounded FIFO is at `QUEUE_MAX_ITEMS` (default 10). Cancel something first; + * the server never silently drops. + * - [TooLarge] — 413, over `QUEUE_ITEM_MAX_BYTES` (default 4 KiB, server-configurable — the client + * deliberately does NOT pre-validate size, which would invent a policy it cannot know). + * - [Disabled] — 503, `QUEUE_ENABLED=0` on this host: hide the affordance, do not retry. + * - [SessionGone] — 404, unknown or already-exited session. + * - [RateLimited] — 429 from the shared `QUEUE_RATE_MAX = 20`/minute/IP bucket. **Never auto-retry**; + * a retry loop just burns the same budget a real enqueue needs. + * - [Rejected] — any other 4xx/5xx, carrying the server's SAFE `error` string verbatim (`message` may + * be `null` when the body is empty, e.g. the Origin guard's bare 403). + */ +public sealed interface QueueWriteOutcome { + /** 200 — accepted; [length] is the new pending depth (0 for a cleared queue). */ + public data class Ok(val length: Int) : QueueWriteOutcome + + /** 409 — the bounded FIFO is full (`QUEUE_MAX_ITEMS`). */ + public data object Full : QueueWriteOutcome + + /** 413 — the prompt exceeds the server's per-item byte cap. */ + public data object TooLarge : QueueWriteOutcome + + /** 503 — the queue feature is switched off on this host. */ + public data object Disabled : QueueWriteOutcome + + /** 404 — the session is unknown or has already exited. */ + public data object SessionGone : QueueWriteOutcome + + /** 429 — shared per-IP queue bucket. Do NOT auto-retry. */ + public data object RateLimited : QueueWriteOutcome + + /** Any other failure, with the server's inert message (null when unparseable/empty). */ + public data class Rejected(val status: Int, val message: String?) : QueueWriteOutcome +} + +/** + * Read the new depth out of a queue write's 200 body (`{ length }`). The status already says it + * succeeded, so a missing/garbled body degrades to `0` rather than throwing — the next `queue` read or + * `queue` WS frame re-establishes the true depth. + */ +internal fun decodeQueueDepth(bytes: ByteArray): Int = + LossyDecode.objectOrNull(bytes, QueueSnapshot.serializer())?.length ?: 0 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt index 2a6df4e..54fab0b 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt @@ -2,6 +2,8 @@ package wang.yaojia.webterm.api.models import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor @@ -9,7 +11,9 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.intOrNull import wang.yaojia.webterm.wire.ClaudeStatus import java.util.UUID @@ -36,6 +40,23 @@ internal object ClaudeStatusSerializer : KSerializer { override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire) } +/** + * A tolerant `Int?` field decoder: a wrong-typed/garbled value degrades to `null` instead of failing + * the whole enclosing object. Used for ADDITIVE OPTIONAL numeric fields (e.g. + * `LiveSessionInfo.queueLength`) where dropping the entry would hide a running session for the sake of + * a badge. Required numeric fields deliberately keep the strict built-in behaviour. + */ +internal object LossyIntSerializer : KSerializer { + private val delegate: KSerializer = Int.serializer().nullable + override val descriptor: SerialDescriptor = delegate.descriptor + override fun serialize(encoder: Encoder, value: Int?) = delegate.serialize(encoder, value) + override fun deserialize(decoder: Decoder): Int? { + val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder) + val primitive = json.decodeJsonElement() as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content.toIntOrNull() else primitive.intOrNull + } +} + /** * A `List` serializer that drops malformed elements and degrades a non-array to `[]` — the * Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`, diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt new file mode 100644 index 0000000..09135b5 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt @@ -0,0 +1,88 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.Serializable + +/** + * w6/G1 — how far the checked-out branch has drifted from its upstream (`src/types.ts` `SyncState`). + * + * ## The one rule this whole feature turns on + * `ahead`/`behind` are computed against `@{u}`, a **locally cached** remote ref that only a `fetch` + * moves. That asymmetry decides what may be trusted: + * + * - [ahead] needs only local refs, so it is always trustworthy. + * - [behind] is only meaningful if [lastFetchMs] is recent. A stale FETCH_HEAD reports `behind = 0` + * for a branch that is in fact many commits behind — the server's own commit message records finding + * exactly that (`↓0` while FETCH_HEAD had not moved in 19 days). + * - **No upstream leaves [ahead] and [behind] undefined**, which is NOT the same as zero. A fresh + * worktree branch normally has no upstream, and rendering it as "in sync" is the single easiest bug + * to ship here. [isFullySynced] is the only sanctioned way to ask "may I render the all-clear?" and + * it answers false whenever anything is unknown. Do not re-derive that test at a call site. + * + * Every field is optional because every one of them degrades independently on the server (no upstream, + * detached HEAD, never fetched, not a git repo). + */ +@Serializable +public data class SyncState( + /** e.g. `origin/develop`; null ⇒ the branch tracks nothing. */ + val upstream: String? = null, + /** Commits on HEAD not on `@{u}`. Null ⇒ unknown (no upstream), NOT zero. */ + val ahead: Int? = null, + /** Commits on `@{u}` not on HEAD. Null ⇒ unknown. Trust only when [isFetchFresh]. */ + val behind: Int? = null, + /** FETCH_HEAD mtime in ms; null ⇒ never fetched. */ + val lastFetchMs: Long? = null, + /** HEAD is not on a branch ⇒ no branch, no ahead/behind, and fetch/push are not offerable. */ + val detached: Boolean = false, +) { + + /** True when there is an upstream to compare against at all. */ + public val hasUpstream: Boolean get() = upstream != null + + /** + * True when [lastFetchMs] is recent enough for [behind] to mean anything. + * + * @param nowMs caller-supplied clock so this stays pure and testable. + */ + public fun isFetchFresh(nowMs: Long): Boolean { + val fetched = lastFetchMs ?: return false + val age = nowMs - fetched + // A clock skew that puts the fetch in the future is not evidence of freshness. + return age in 0..FETCH_FRESH_WINDOW_MS + } + + /** True when [behind] should be shown with a "stale — I have not checked" qualifier. */ + public fun isBehindStale(nowMs: Long): Boolean = hasUpstream && !isFetchFresh(nowMs) + + /** + * The ONLY state that may render as the green all-clear: nothing to push, nothing to pull, and a + * fresh enough fetch to justify saying so. + * + * Green means "I checked, you can ignore this". Getting it wrong is lying to the user, so anything + * unknown — no upstream, detached, never fetched, stale fetch, null counts — is false. + */ + public fun isFullySynced(nowMs: Long): Boolean = + hasUpstream && !detached && ahead == 0 && behind == 0 && isFetchFresh(nowMs) + + public companion object { + /** + * How long a fetch stays "fresh". Mirrors the server rule that `behind` is flagged stale once + * FETCH_HEAD is older than an hour (w6/G1). + */ + public const val FETCH_FRESH_WINDOW_MS: Long = 60L * 60L * 1000L + } +} + +/** + * w6/G7 — the git state of ONE worktree, fetched lazily per row via `GET /projects/worktree/state` + * (`src/types.ts` `WorktreeState`). + * + * Deliberately narrower than `ProjectDetail`: N rows must not each pay for a worktree listing and a + * CLAUDE.md read that nothing renders. + */ +@Serializable +public data class WorktreeState( + val path: String, + val branch: String? = null, + val sync: SyncState? = null, + val dirtyCount: Int? = null, +) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt index 60005c4..e4e6035 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt @@ -11,12 +11,17 @@ import wang.yaojia.webterm.api.models.PrStatus import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.PruneWorktreesResult +import wang.yaojia.webterm.api.models.QueueSnapshot +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.api.models.decodeQueueDepth +import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult import wang.yaojia.webterm.api.models.SessionPreview import wang.yaojia.webterm.api.models.StageResult import wang.yaojia.webterm.api.models.UiConfig import wang.yaojia.webterm.api.models.UiPrefs +import wang.yaojia.webterm.api.models.WorktreeState import wang.yaojia.webterm.api.models.decodeGitError import wang.yaojia.webterm.api.models.decodeGitPayload import wang.yaojia.webterm.wire.HostEndpoint @@ -135,6 +140,19 @@ public class ApiClient( } } + /** + * `GET /live-sessions/:id/queue` (W2) — the pending prompt queue: depth + the queued texts, + * verbatim. READ-ONLY, so no Origin (the server guards only the two writes). 404 → the session is + * gone; a non-object body → [ApiClientError.InvalidResponseBody] rather than a fake empty queue + * (claiming "nothing queued" when entries exist would mislead a cancel-all decision). + */ + public suspend fun queue(id: UUID): QueueSnapshot { + val response = perform(Endpoints.queue(id)) + requireOk(response) + return LossyDecode.objectOrNull(response.body, QueueSnapshot.serializer()) + ?: throw ApiClientError.InvalidResponseBody + } + /** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws * `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */ public suspend fun prefs(): UiPrefs { @@ -180,6 +198,52 @@ public class ApiClient( } } + // ── G: W2 prompt queue (enqueue / cancel-all) → QueueWriteOutcome ────────────────────── + + /** + * `POST /live-sessions/:id/queue` (W2) — queue a follow-up prompt the server injects into the PTY + * the next time Claude goes idle. Works with zero tabs attached, which is the whole point. + * + * [text] travels VERBATIM (invariant #9 — raw keyboard bytes for a shell); [appendEnter] is a flag + * the SERVER turns into a trailing `\r`, so callers must not append one. An empty prompt is + * rejected as [ApiClientError.QueueTextEmpty] before any network I/O; size is NOT pre-checked + * client-side (the byte cap is server config) — an over-cap prompt comes back as + * [QueueWriteOutcome.TooLarge]. + */ + public suspend fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean = true): QueueWriteOutcome { + if (text.isEmpty()) throw ApiClientError.QueueTextEmpty + return queueWrite(Endpoints.enqueueFollowup(id, text, appendEnter)) + } + + /** + * `DELETE /live-sessions/:id/queue` (W2) — cancel every pending entry (escape hatch). 200 → + * [QueueWriteOutcome.Ok] with depth 0. + */ + public suspend fun clearQueue(id: UUID): QueueWriteOutcome = queueWrite(Endpoints.clearQueue(id)) + + /** + * Shared status mapping for the two guarded queue writes. Distinct typed outcomes because each one + * demands different UI behaviour (see [QueueWriteOutcome]); in particular a 429 from the shared + * `QUEUE_RATE_MAX = 20`/min/IP bucket is surfaced, NEVER auto-retried. Only 409/413/503 are + * POST-specific — the DELETE handler cannot produce them. + */ + private suspend fun queueWrite(route: ApiRoute): QueueWriteOutcome { + val response = perform(route) + return when (response.status) { + HttpStatus.OK -> QueueWriteOutcome.Ok(decodeQueueDepth(response.body)) + HttpStatus.CONFLICT -> QueueWriteOutcome.Full + HttpStatus.PAYLOAD_TOO_LARGE -> QueueWriteOutcome.TooLarge + HttpStatus.SERVICE_UNAVAILABLE -> QueueWriteOutcome.Disabled + HttpStatus.NOT_FOUND -> QueueWriteOutcome.SessionGone + HttpStatus.TOO_MANY_REQUESTS -> QueueWriteOutcome.RateLimited + // `decodeGitError` reads the generic `{ error }` failure shape the queue routes share with + // the git ones (400/403 here) — reused rather than duplicated. + in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX -> + QueueWriteOutcome.Rejected(response.status, decodeGitError(response.body)) + else -> throw ApiClientError.UnexpectedStatus(response.status) + } + } + // ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ────────────── /** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */ @@ -206,6 +270,36 @@ public class ApiClient( public suspend fun gitPush(path: String): GitWriteOutcome = gitWrite(Endpoints.gitPush(path), PushResult.serializer()) + /** + * `POST /projects/git/fetch` (w6/G2) — refresh `refs/remotes` so `behind` becomes trustworthy. + * + * Shares the guarded-write dispatch, so a disabled kill-switch or an Origin failure both surface as + * [GitWriteOutcome.Rejected] with the server's safe message. Fetch has its OWN rate-limit bucket on + * the server precisely so refreshes cannot eat the budget a real push needs — surface a 429 rather + * than retrying. + */ + public suspend fun gitFetch(path: String): GitWriteOutcome = + gitWrite(Endpoints.gitFetch(path), FetchResult.serializer()) + + /** + * `GET /projects/worktree/state?path=` (w6/G7) — the per-row git state of ONE worktree. + * + * Mapped like the other project reads. A malformed body throws rather than degrading to an empty + * state: a row that silently claims "clean, in sync" would be a lie, and the caller is expected to + * isolate this failure to its own row (the panel must still render). + */ + public suspend fun worktreeState(path: String): WorktreeState { + if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid + val response = perform(Endpoints.worktreeState(path)) + return when (response.status) { + HttpStatus.OK -> LossyDecode.objectOrNull(response.body, WorktreeState.serializer()) + ?: throw ApiClientError.InvalidResponseBody + HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid + HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound + else -> throw ApiClientError.UnexpectedStatus(response.status) + } + } + /** * Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the * decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected] diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt index 3bd6d2f..2104c86 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt @@ -44,6 +44,11 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u /** 500 from `GET /projects/detail` — the server failed reading the repo. */ public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。") + /** An empty follow-up prompt was passed to `POST /live-sessions/:id/queue` (which the server 400s + * as `text must be a non-empty string`). Raised client-side, before any network I/O — mirrors the + * [ProjectPathInvalid] precedent. */ + public data object QueueTextEmpty : ApiClientError("要排队的内容为空——请先输入要发送的提示词。") + /** 500 from `GET /projects/log` — the server failed reading the git log. */ public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。") diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt index cbddb4c..9c795bb 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt @@ -12,8 +12,11 @@ internal object HttpStatus { const val BAD_REQUEST = 400 const val FORBIDDEN = 403 const val NOT_FOUND = 404 + const val CONFLICT = 409 + const val PAYLOAD_TOO_LARGE = 413 const val TOO_MANY_REQUESTS = 429 const val INTERNAL_SERVER_ERROR = 500 + const val SERVICE_UNAVAILABLE = 503 /** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */ const val CLIENT_ERROR_MIN = 400 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt index 21ac018..4fdbfca 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt @@ -12,10 +12,10 @@ import java.util.UUID * `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token** * pair (this client uses FCM, not APNs/VAPID). * - * RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui` - * · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs` - * G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs` - * · `POST|DELETE /push/fcm-token` + * RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue` + * · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs` + * G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue` + * · `POST /hook/decision` · `PUT /prefs` · `POST|DELETE /push/fcm-token` * * KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD * of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` + @@ -54,9 +54,29 @@ internal object Endpoints { percentEncodedQuery = "path=${percentEncode(path)}", ) + /** + * `GET /projects/worktree/state?path=` — RO (w6/G7). Deliberately NARROWER than + * `/projects/detail`: it is fetched once per worktree row, so N rows must not each pay for a + * worktree listing and a CLAUDE.md read that nothing renders. + */ + fun worktreeState(path: String): ApiRoute = + ApiRoute( + HttpMethod.GET, + "/projects/worktree/state", + OriginPolicy.READ_ONLY, + percentEncodedQuery = "path=${percentEncode(path)}", + ) + fun getPrefs(): ApiRoute = ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY) + /** + * `GET /live-sessions/:id/queue` — RO (W2). Same threat model as `GET /live-sessions`, so the + * server stamps no Origin guard on it and neither may we. + */ + fun queue(id: UUID): ApiRoute = + ApiRoute(HttpMethod.GET, queuePath(id), OriginPolicy.READ_ONLY) + /** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */ fun projectPr(path: String): ApiRoute = ApiRoute( @@ -148,10 +168,50 @@ internal object Endpoints { fun gitCommit(path: String, message: String): ApiRoute = jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message)) + /** + * `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates + * `refs/remotes`: it is state-changing and spawns a network git process, so it goes through the + * single Origin-stamping point like every other write. The remote is derived SERVER-side and no + * remote or refspec is ever read from the body, so a client cannot aim it at an arbitrary URL. + * It is NOT a pull: no working tree, no index, no merge. + */ + fun gitFetch(path: String): ApiRoute = + jsonBodyRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path)) + /** `POST /projects/git/push` — `{ path }`. */ fun gitPush(path: String): ApiRoute = jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path)) + // ── G: W2 prompt queue (enqueue / cancel-all) ────────────────────────────────────────── + + /** + * `POST /live-sessions/:id/queue` — G. Body is exactly `{ text, appendEnter }` (express.json, + * 16 kb limit). + * + * [text] is RAW KEYBOARD INPUT for a shell and is carried VERBATIM (invariant #9): JSON string + * encoding is the only transformation, and it round-trips byte-exactly through `express.json`. + * Never trim/escape/normalise it here. [appendEnter] is a FLAG — the server materialises the + * trailing `\r` so the stored entry is byte-identical to a keystroke; the client must not append + * one itself. + */ + fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean): ApiRoute = + jsonBodyRoute( + HttpMethod.POST, + queuePath(id), + EnqueueBody.serializer(), + EnqueueBody(text, appendEnter), + ) + + /** + * `DELETE /live-sessions/:id/queue` — G, cancel-all escape hatch. Carries **no** body: unlike + * `DELETE /projects/worktree` (the body-carrying DELETE precedent), this handler reads only `:id` + * and clears the whole FIFO, so a body would be dead weight the server never parses. + */ + fun clearQueue(id: UUID): ApiRoute = + ApiRoute(HttpMethod.DELETE, queuePath(id), OriginPolicy.GUARDED) + + private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue" + /** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */ private fun jsonBodyRoute( method: HttpMethod, @@ -216,4 +276,10 @@ internal object Endpoints { @Serializable private data class PushBody(val path: String) + + @kotlinx.serialization.Serializable + private data class FetchBody(val path: String) + + @Serializable + private data class EnqueueBody(val text: String, val appendEnter: Boolean) } diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt index 4deadf2..51b95fd 100644 --- a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt @@ -253,6 +253,137 @@ class DeviceEnrollmentClientTest { assertTrue(transport.recordedRequests.isEmpty()) } + @Test + fun renewDecodesTheRealServerResponseWhichCarriesNoDeviceId() = runTest { + // THE REAL WIRE SHAPE: `/device/:id/renew` answers `{cert, caChain, notAfter}` — it does NOT + // echo deviceId/notBefore/renewAfter (only `/device/enroll` does; see + // control-plane/src/api/renew.ts). Decoding it against the enroll DTO (deviceId required) + // made every silent renewal fail as MalformedResponse. The device id comes from the path we + // called, which is authoritative anyway. + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, + body = """{"cert":"MAEC","caChain":["MAED"],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(), + ) + + val result = client.renew("dev-9", byteArrayOf(0x02)) + + assertEquals("dev-9", result.deviceId, "the device id comes from the path segment we called") + assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter) + assertNull(result.notBefore, "the renew 201 carries no notBefore") + assertNull(result.renewAfter, "the renew 201 carries no renewAfter — the client derives it") + assertEquals(1, result.caChain.size) + } + + // ── recover (EXPIRED-leaf escape hatch — plain HTTPS, cert in the BODY, never mTLS) ────────── + + @Test + fun recoverPostsTheExpiredLeafAndAFreshCsrWithNoAuthorizationHeader() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201, + body = """{"cert":"MAEC","caChain":[],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(), + ) + val expiredLeaf = byteArrayOf(0x30, 0x11) + val csr = byteArrayOf(0x30, 0x22) + + val result = client.recover("dev-9", expiredLeaf, csr) + + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/device/dev-9/recover", request.url) + // Recovery is NOT mTLS-authenticated and carries no bearer: an expired client certificate + // cannot authenticate its own re-issuance (no TLS terminator will even forward one), so the + // lapsed cert travels in the BODY and proof-of-possession comes from the CSR self-signature. + assertNull(request.headers["Authorization"], "recovery carries no bearer") + val obj = bodyObject(request) + assertEquals(setOf("cert", "csr"), obj.keys, "the /recover schema is .strict() on {cert, csr}") + assertEquals(Base64.getEncoder().encodeToString(expiredLeaf), obj["cert"]!!.jsonPrimitive.content) + assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content) + assertEquals("dev-9", result.deviceId) + assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter) + } + + @Test + fun recoverPercentEncodesTheDeviceIdPathSegment() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/a%2Fb/recover", status = 201, + body = """{"cert":"MAEC","caChain":[]}""".toByteArray(), + ) + client.recover("a/b", byteArrayOf(1), byteArrayOf(2)) + assertEquals("$BASE/device/a%2Fb/recover", transport.recordedRequests.single().url) + } + + @Test + fun recoverRejectsEmptyArgumentsBeforeAnyNetworkIo() = runTest { + assertEquals( + DeviceEnrollmentError.InvalidRequest, + runCatching { client.recover("", byteArrayOf(1), byteArrayOf(1)) }.exceptionOrNull(), + ) + assertEquals( + DeviceEnrollmentError.InvalidRequest, + runCatching { client.recover("d", ByteArray(0), byteArrayOf(1)) }.exceptionOrNull(), + ) + assertEquals( + DeviceEnrollmentError.InvalidRequest, + runCatching { client.recover("d", byteArrayOf(1), ByteArray(0)) }.exceptionOrNull(), + ) + assertTrue(transport.recordedRequests.isEmpty(), "no I/O for a request that cannot succeed") + } + + @Test + fun recoverSurfacesA401BeyondTheGraceWindowWithoutLeakingTheCert() = runTest { + // Past `DEFAULT_EXPIRED_RENEW_GRACE_MS` the control plane answers a uniform 401. The raised + // error must carry the STATUS + the server's `error` code only — never the submitted cert or + // CSR bytes, which would put credential material into a crash log. + val expiredLeaf = byteArrayOf(0x30, 0x11, 0x22, 0x33) + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 401, + body = """{"error":"rejected"}""".toByteArray(), + ) + + val error = runCatching { client.recover("dev-9", expiredLeaf, byteArrayOf(0x30, 0x44)) }.exceptionOrNull() + + assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error) + val rendered = "${error!!.message} $error" + assertFalse( + rendered.contains(Base64.getEncoder().encodeToString(expiredLeaf)), + "the raised error must not carry the certificate bytes", + ) + assertFalse(rendered.contains("MDE"), "no base64 credential material in the error rendering") + } + + @Test + fun recoverSurfacesA403ForARevokedDevice() = runTest { + // Recovery NEVER bypasses revocation (renew.test.ts CP6e) — surface the 403 verbatim. + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 403, + body = """{"error":"rejected"}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.Http(403, "rejected"), + runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(), + ) + } + + @Test + fun recoverThrowsMalformedResponseOnAnUndecodable201() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201, + body = """{"cert":"@@not-base64@@"}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.MalformedResponse, + runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(), + ) + } + + @Test + fun theBaseUrlIsReadableSoTheRotationTargetCanBePersisted() = runTest { + // The rotation driver must renew against the SAME control plane the device enrolled with; the + // enroller persists this alongside the enrollment record. + assertEquals(BASE, client.baseUrl) + assertEquals(BASE, DeviceEnrollmentClient("$BASE/", transport).baseUrl, "a trailing slash is trimmed") + } + // ── isRenewalDue seam ────────────────────────────────────────────────────────────────────── @Test diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicyTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicyTest.kt new file mode 100644 index 0000000..7b7b315 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicyTest.kt @@ -0,0 +1,228 @@ +package wang.yaojia.webterm.api.enroll + +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.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Duration +import java.time.Instant + +/** + * The PURE rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`, + * `TerminalGridMath` — a policy is a function, so it is exhaustively testable with no clock, no + * keystore and no network). Ports the iOS `CertificateRotationSchedulerTests` timing cases and adds + * the two the phone track needs and iOS lacks: the EXPIRED-leaf `/device/:id/recover` window and the + * terminal beyond-grace state. + */ +class RotationPolicyTest { + private companion object { + val NOT_BEFORE: Instant = Instant.parse("2026-08-06T00:00:00Z") + val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z") + // notBefore + 2/3 · 61d lifetime — the value the control plane itself computes. + val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z") + + fun state( + notAfter: Instant? = NOT_AFTER, + renewAfter: Instant? = RENEW_AFTER, + ): DeviceRenewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter) + } + + // ── renewAfter derivation (the client MUST derive it: renew/recover 201s carry no renewAfter) ── + + @Test + fun renewAfterIsTwoThirdsThroughTheLeafLifetime() { + // The control plane computes `notBefore + floor(2/3 · lifetime)` (device-enroll.ts + // DEFAULT_RENEW_FRACTION); the client must land on the same instant from the leaf alone, + // because `/device/:id/renew` and `/device/:id/recover` return `{cert, caChain, notAfter}` + // ONLY — no renewAfter. Without this derivation rotation would fire exactly once, ever. + assertEquals(RENEW_AFTER, RotationPolicy.renewAfterFor(NOT_BEFORE, NOT_AFTER)) + } + + @Test + fun renewAfterIsNullWhenEitherBoundIsUnknown() { + assertNull(RotationPolicy.renewAfterFor(null, NOT_AFTER)) + assertNull(RotationPolicy.renewAfterFor(NOT_BEFORE, null)) + } + + @Test + fun renewAfterIsNullForANonsensicalWindow() { + // notAfter before notBefore is not a lifetime — degrade to "unknown" (fail-safe: the TLS + // stack is the real gate) instead of inventing a past instant that would renew-storm. + assertNull(RotationPolicy.renewAfterFor(NOT_AFTER, NOT_BEFORE)) + } + + // ── the wait branch ──────────────────────────────────────────────────────────────────────── + + @Test + fun aLeafInsideItsRenewWindowIsNotDue() { + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide(state(), now = RENEW_AFTER.minusSeconds(1)), + ) + } + + @Test + fun aMissingRenewAfterNeverTriggers() { + // iOS fail-safe, ported verbatim: absent timing NEVER renews — the TLS stack is the real gate + // and the scheduler only pre-empts expiry. + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide(state(renewAfter = null), now = NOT_AFTER.minusSeconds(1)), + ) + assertFalse(state(renewAfter = null).isRenewalDue(NOT_AFTER.minusSeconds(1))) + } + + @Test + fun anUnknownNotAfterFallsBackToTheRenewAfterBranch() { + // A cert whose expiry could not be read must not be treated as expired (that would push a + // working device into recovery); it is judged solely on the advisory renew timing. + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER.minusSeconds(1)), + ) + assertEquals( + RotationDecision.RENEW, + RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER), + ) + } + + // ── the renew branch ─────────────────────────────────────────────────────────────────────── + + @Test + fun renewFiresAtTheRenewAfterBoundary() { + assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = RENEW_AFTER)) + assertTrue(state().isRenewalDue(RENEW_AFTER), "the >= boundary renews (iOS parity)") + } + + @Test + fun renewStillFiresAtTheLastInstantBeforeExpiry() { + assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = NOT_AFTER)) + } + + // ── the recover branch (expired leaf — the phone-track deadlock escape hatch) ─────────────── + + @Test + fun anExpiredLeafInsideTheGraceWindowRecovers() { + // mTLS `/renew` cannot authenticate an expired leaf (nginx refuses to forward one at all), so + // the only route left is the plain-HTTPS `/device/:id/recover`. + assertEquals( + RotationDecision.RECOVER, + RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(8))), + ) + } + + @Test + fun recoveryIsStillAvailableAtTheExactGraceBoundary() { + val grace = Duration.ofDays(30) + assertEquals( + RotationDecision.RECOVER, + RotationPolicy.decide(state(), now = NOT_AFTER.plus(grace), expiredRecoveryGrace = grace), + ) + } + + @Test + fun aLeafExpiredBeyondTheGraceWindowRequiresAFreshEnroll() { + assertEquals( + RotationDecision.RE_ENROLL_REQUIRED, + RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(31))), + ) + } + + @Test + fun aZeroGraceDisablesRecoveryEntirely() { + // Mirrors the control plane's `expiredRenewGraceMs = 0` posture (renew.test.ts CP6e). + assertEquals( + RotationDecision.RE_ENROLL_REQUIRED, + RotationPolicy.decide( + state(), + now = NOT_AFTER.plusMillis(1), + expiredRecoveryGrace = Duration.ZERO, + ), + ) + } + + @Test + fun theTerminalStateWinsOverTheFailureBackoff() { + // Beyond grace nothing can ever succeed, so the answer must be the terminal one even while a + // backoff is armed — otherwise the user is told "retrying" forever (the exact failure mode + // the agent's 6380 identical warnings came from). + assertEquals( + RotationDecision.RE_ENROLL_REQUIRED, + RotationPolicy.decide( + state(), + now = NOT_AFTER.plus(Duration.ofDays(31)), + lastFailureAt = NOT_AFTER.plus(Duration.ofDays(31)).minusSeconds(1), + ), + ) + } + + // ── the backoff branch (a failed attempt must not hammer the control plane) ───────────────── + + @Test + fun aRecentFailureBacksOffInsteadOfRetryingImmediately() { + // Every foreground fires a pass; the control plane rate-limits renewals to 30/hour/identity, + // so an un-throttled retry turns one failure into a 429 storm. + val now = RENEW_AFTER.plus(Duration.ofMinutes(1)) + assertEquals( + RotationDecision.BACKING_OFF, + RotationPolicy.decide(state(), now = now, lastFailureAt = now.minus(Duration.ofMinutes(1))), + ) + } + + @Test + fun theBackoffExpiresAndTheRenewIsRetried() { + val now = RENEW_AFTER.plus(Duration.ofHours(1)) + assertEquals( + RotationDecision.RENEW, + RotationPolicy.decide( + state(), + now = now, + lastFailureAt = now.minus(RotationPolicy.DEFAULT_FAILURE_BACKOFF), + ), + ) + } + + @Test + fun aRecentFailureAlsoThrottlesTheRecoveryPath() { + val now = NOT_AFTER.plus(Duration.ofDays(8)) + assertEquals( + RotationDecision.BACKING_OFF, + RotationPolicy.decide(state(), now = now, lastFailureAt = now.minusSeconds(30)), + ) + } + + @Test + fun aFailureNeverTurnsAnIdleLeafIntoAnAttempt() { + // NOT_DUE has no attempt to throttle — reporting BACKING_OFF there would be a lie. + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide( + state(), + now = RENEW_AFTER.minusSeconds(1), + lastFailureAt = RENEW_AFTER.minusSeconds(2), + ), + ) + } + + // ── boundary validation ──────────────────────────────────────────────────────────────────── + + @Test + fun negativeDurationsAreRejectedAtTheBoundary() { + assertThrows(IllegalArgumentException::class.java) { + RotationPolicy.decide(state(), now = RENEW_AFTER, expiredRecoveryGrace = Duration.ofDays(-1)) + } + assertThrows(IllegalArgumentException::class.java) { + RotationPolicy.decide(state(), now = RENEW_AFTER, failureBackoff = Duration.ofMinutes(-1)) + } + } + + @Test + fun theStateCarriesNoCredentialInItsToString() { + // The rotation state is logged/surfaced; it must never be able to carry key or cert bytes. + val rendered = state().toString() + assertTrue(rendered.contains("dev-1"), "the non-secret device id is the only identifier here") + assertFalse(rendered.contains("MII"), "no DER/PEM material may appear in a loggable rendering") + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PromptQueueTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PromptQueueTest.kt new file mode 100644 index 0000000..034c531 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PromptQueueTest.kt @@ -0,0 +1,74 @@ +package wang.yaojia.webterm.api.models + +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.DisplayName +import org.junit.jupiter.api.Test + +/** + * Tolerant decode for the W2 queue read (`GET /live-sessions/:id/queue` → `{length, items}`) and for + * the additive `LiveSessionInfo.queueLength` the badge reads. The server is UNTRUSTED here: unknown + * keys, missing keys and wrong-typed items must degrade, never crash — and the queued strings, which + * are raw keyboard bytes, must come back verbatim. + */ +@DisplayName("W2 queue models — tolerant decode + verbatim items") +class PromptQueueTest { + private fun snapshot(json: String): QueueSnapshot? = + LossyDecode.objectOrNull(json.toByteArray(), QueueSnapshot.serializer()) + + @Test + fun `decodes length and items`() { + val s = snapshot("""{"length":2,"items":["first","second"]}""")!! + assertEquals(2, s.length) + assertEquals(listOf("first", "second"), s.items) + } + + @Test + fun `queued items keep control characters and CJK verbatim`() { + //  + TAB + CR + CJK, exactly as the server stored the keystroke bytes. + val s = snapshot("""{"length":1,"items":["\t你好\r"]}""")!! + assertEquals("\t你好\r", s.items.single()) + } + + @Test + fun `an empty queue and unknown keys both decode`() { + val s = snapshot("""{"length":0,"items":[],"futureField":true}""")!! + assertEquals(0, s.length) + assertTrue(s.items.isEmpty()) + } + + @Test + fun `missing fields fall back to an empty queue and a non-object body is null`() { + val s = snapshot("{}")!! + assertEquals(0, s.length) + assertTrue(s.items.isEmpty()) + + assertNull(snapshot("[]")) + assertNull(snapshot("garbage")) + } + + // ── LiveSessionInfo.queueLength (additive optional, src/types.ts:318) ───────────────────── + + private val base = + """{"id":"11111111-2222-3333-4444-555555555555","createdAt":1,"clientCount":0,"exited":false,"cols":80,"rows":24""" + + private fun info(extra: String): LiveSessionInfo? = + LossyDecode.objectOrNull("$base$extra}".toByteArray(), LiveSessionInfo.serializer()) + + @Test + fun `queueLength decodes when present`() { + assertEquals(4, info(""","queueLength":4""")!!.queueLength) + } + + @Test + fun `queueLength is null on a pre-W2 server that omits it`() { + assertNull(info("")!!.queueLength) + } + + @Test + fun `a wrong-typed queueLength does not drop the whole session`() { + // A garbled value must not make a running session invisible — the field degrades to null. + assertNull(info(",\"queueLength\":\"lots\"")!!.queueLength) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt new file mode 100644 index 0000000..78bce28 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt @@ -0,0 +1,141 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.json.Json +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.DisplayName +import org.junit.jupiter.api.Test + +/** + * w6/G1 sync state. + * + * The load-bearing rule, quoted from the server commit that introduced the feature: *"Exactly ONE state + * may render green: ↑0 ↓0 AND a fresh fetch. Green means 'I checked, ignore this'; getting it wrong is + * lying to the user."* Most of this file exists to pin the ways that could go wrong — above all the + * no-upstream fall-through, which the server's own commit message calls "the easiest bug to ship here". + */ +@DisplayName("SyncState — when the all-clear may be rendered") +class SyncStateTest { + + private val json = Json { ignoreUnknownKeys = true } + private val now = 1_700_000_000_000L + private fun fresh() = now - 60_000L // a minute ago + private fun stale() = now - (SyncState.FETCH_FRESH_WINDOW_MS + 60_000L) + + @Test + @DisplayName("decodes the full server shape") + fun decodesFull() { + val s = json.decodeFromString( + """{"upstream":"origin/develop","ahead":9,"behind":0,"lastFetchMs":$now,"detached":false}""", + ) + assertEquals("origin/develop", s.upstream) + assertEquals(9, s.ahead) + assertEquals(0, s.behind) + } + + @Test + @DisplayName("every field is optional — each degrades independently on the server") + fun decodesEmpty() { + val s = json.decodeFromString("{}") + assertNull(s.upstream) + assertNull(s.ahead) + assertNull(s.behind) + assertNull(s.lastFetchMs) + assertFalse(s.detached) + assertFalse(s.hasUpstream) + } + + // ── the green state ────────────────────────────────────────────────────────────────────────────── + + @Test + @DisplayName("↑0 ↓0 with a fresh fetch is the ONLY green state") + fun theOneGreenState() { + val s = SyncState(upstream = "origin/main", ahead = 0, behind = 0, lastFetchMs = fresh()) + assertTrue(s.isFullySynced(now)) + } + + @Test + @DisplayName("NO UPSTREAM is never green — the fall-through the server calls the easiest bug here") + fun noUpstreamIsNeverGreen() { + // The normal state of a fresh worktree branch. ahead/behind are UNDEFINED, not zero. + val s = SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh()) + assertFalse(s.isFullySynced(now), "a branch tracking nothing has not been verified as in sync") + assertFalse(s.hasUpstream) + } + + @Test + @DisplayName("undefined counts are not zero, even with an upstream and a fresh fetch") + fun undefinedCountsAreNotZero() { + assertFalse(SyncState("origin/main", ahead = null, behind = 0, lastFetchMs = fresh()).isFullySynced(now)) + assertFalse(SyncState("origin/main", ahead = 0, behind = null, lastFetchMs = fresh()).isFullySynced(now)) + } + + @Test + @DisplayName("a stale fetch is not green — behind=0 from a stale FETCH_HEAD proves nothing") + fun staleFetchIsNotGreen() { + val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = stale()) + assertFalse(s.isFullySynced(now)) + assertTrue(s.isBehindStale(now)) + } + + @Test + @DisplayName("never fetched is not green") + fun neverFetchedIsNotGreen() { + val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = null) + assertFalse(s.isFullySynced(now)) + assertFalse(s.isFetchFresh(now)) + } + + @Test + @DisplayName("a detached HEAD is not green") + fun detachedIsNotGreen() { + val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = fresh(), detached = true) + assertFalse(s.isFullySynced(now)) + } + + @Test + @DisplayName("anything unpushed or unpulled is not green") + fun pendingWorkIsNotGreen() { + assertFalse(SyncState("origin/main", ahead = 1, behind = 0, lastFetchMs = fresh()).isFullySynced(now)) + assertFalse(SyncState("origin/main", ahead = 0, behind = 1, lastFetchMs = fresh()).isFullySynced(now)) + } + + // ── freshness edges ────────────────────────────────────────────────────────────────────────────── + + @Test + @DisplayName("a fetch timestamp in the future is not treated as fresh") + fun futureFetchIsNotFresh() { + val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now + 60_000L) + assertFalse(s.isFetchFresh(now), "clock skew is not evidence that we checked") + assertFalse(s.isFullySynced(now)) + } + + @Test + @DisplayName("the freshness boundary is inclusive at exactly the window") + fun boundaryInclusive() { + val atEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS) + assertTrue(atEdge.isFetchFresh(now)) + val pastEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS - 1) + assertFalse(pastEdge.isFetchFresh(now)) + } + + @Test + @DisplayName("staleness is only claimed when there is an upstream to be stale about") + fun noUpstreamIsNotStale() { + assertFalse(SyncState(upstream = null, lastFetchMs = null).isBehindStale(now)) + } + + @Test + @DisplayName("WorktreeState decodes the narrow per-row shape") + fun worktreeStateDecodes() { + val w = json.decodeFromString( + """{"path":"/repo/.claude/worktrees/x","branch":"wt-x","dirtyCount":3, + "sync":{"upstream":"origin/wt-x","ahead":2}}""", + ) + assertEquals("wt-x", w.branch) + assertEquals(3, w.dirtyCount) + assertEquals(2, w.sync?.ahead) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientQueueTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientQueueTest.kt new file mode 100644 index 0000000..0c494ac --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientQueueTest.kt @@ -0,0 +1,141 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import java.util.UUID + +/** + * Status → outcome mapping for the W2 prompt queue, read off the server handlers + * (`src/server.ts` ~605/644/654): + * + * POST: 200 `{length}` → [QueueWriteOutcome.Ok] · 409 `{error:"full"}` → `Full` · + * 413 → `TooLarge` · 503 `{error:"queue disabled"}` → `Disabled` · + * 404 `{error:"unknown"|"exited"}` → `SessionGone` · 429 (empty) → `RateLimited` · + * 400/403/anything else → `Rejected(status, safe error)`. + * DELETE: 200 `{length:0}` → `Ok(0)` · 404 → `SessionGone` · 403 → `Rejected`. + * GET: 200 → snapshot · 404 → [ApiClientError.SessionNotFound]. + * + * 429 comes from the shared `QUEUE_RATE_MAX = 20`/min/IP bucket and is a DISTINCT outcome precisely + * so no caller auto-retries into it (the `GitWriteOutcome.RateLimited` precedent). + */ +@DisplayName("W2 queue — status mapping") +class ApiClientQueueTest { + private companion object { + const val BASE = "http://h:3000" + val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555") + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + private val queueUrl = "$BASE/live-sessions/$ID/queue" + + private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull() + + private fun queuePost(status: Int, body: String = "") = + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, status = status, body = body.toByteArray()) + + private fun queueDelete(status: Int, body: String = "") = + transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, status = status, body = body.toByteArray()) + + private suspend fun enqueue(text: String = "do the thing") = client.enqueueFollowup(ID, text, appendEnter = true) + + // ── POST ────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a 200 yields Ok carrying the new depth`() = runTest { + queuePost(200, """{"length":3}""") + assertEquals(QueueWriteOutcome.Ok(3), enqueue()) + } + + @Test + fun `a 200 with a garbled body still succeeds with an unknown depth of zero`() = runTest { + queuePost(200, "not json") + assertEquals(QueueWriteOutcome.Ok(0), enqueue()) + } + + @Test + fun `409 full 413 too-large and 503 disabled are distinct typed outcomes`() = runTest { + queuePost(409, """{"error":"full"}""") + assertEquals(QueueWriteOutcome.Full, enqueue()) + + queuePost(413, """{"error":"text too large"}""") + assertEquals(QueueWriteOutcome.TooLarge, enqueue()) + + queuePost(503, """{"error":"queue disabled"}""") + assertEquals(QueueWriteOutcome.Disabled, enqueue()) + } + + @Test + fun `404 unknown or exited is SessionGone`() = runTest { + queuePost(404, """{"error":"unknown"}""") + assertEquals(QueueWriteOutcome.SessionGone, enqueue()) + + queuePost(404, """{"error":"exited"}""") + assertEquals(QueueWriteOutcome.SessionGone, enqueue()) + } + + @Test + fun `429 from the shared 20-per-minute bucket is RateLimited and nothing is retried`() = runTest { + queuePost(429) + assertEquals(QueueWriteOutcome.RateLimited, enqueue()) + // Exactly ONE request went out — a rate-limited enqueue must never auto-retry. + assertEquals(1, transport.recordedRequests.size) + } + + @Test + fun `400 and 403 surface Rejected with the server's safe message`() = runTest { + queuePost(400, """{"error":"text must be a non-empty string"}""") + assertEquals(QueueWriteOutcome.Rejected(400, "text must be a non-empty string"), enqueue()) + + // The Origin guard 403s with an EMPTY body — message degrades to null, never throws. + queuePost(403) + assertEquals(QueueWriteOutcome.Rejected(403, null), enqueue()) + } + + @Test + fun `an odd non-error status is UnexpectedStatus`() = runTest { + queuePost(302) + assertTrue(errorOf { enqueue() } is ApiClientError.UnexpectedStatus) + } + + @Test + fun `an empty prompt is rejected before any network IO`() = runTest { + assertEquals(ApiClientError.QueueTextEmpty, errorOf { client.enqueueFollowup(ID, "", appendEnter = true) }) + assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty prompt") + } + + // ── DELETE ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun `clearQueue maps 200 404 403 and 429`() = runTest { + queueDelete(200, """{"length":0}""") + assertEquals(QueueWriteOutcome.Ok(0), client.clearQueue(ID)) + + queueDelete(404) + assertEquals(QueueWriteOutcome.SessionGone, client.clearQueue(ID)) + + queueDelete(403) + assertEquals(QueueWriteOutcome.Rejected(403, null), client.clearQueue(ID)) + + queueDelete(429) + assertEquals(QueueWriteOutcome.RateLimited, client.clearQueue(ID)) + } + + // ── GET ─────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `queue maps 404 to SessionNotFound and a garbled 200 to InvalidResponseBody`() = runTest { + transport.queueSuccess(url = queueUrl, status = 404) + assertEquals(ApiClientError.SessionNotFound, errorOf { client.queue(ID) }) + + transport.queueSuccess(url = queueUrl, body = "[]".toByteArray()) + assertEquals(ApiClientError.InvalidResponseBody, errorOf { client.queue(ID) }) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/QueueRouteShapeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/QueueRouteShapeTest.kt new file mode 100644 index 0000000..7c72778 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/QueueRouteShapeTest.kt @@ -0,0 +1,162 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import java.util.UUID + +/** + * Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the W2 prompt-queue trio + * (`src/server.ts` ~605/644/654): + * + * - `GET /live-sessions/:id/queue` — read-only ⇒ MUST NOT carry Origin; + * - `POST /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin + JSON body; + * - `DELETE /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin, and (unlike + * `DELETE /projects/worktree`, the precedent that DOES carry a body) MUST NOT carry a body — the + * server handler reads nothing but `:id` and clears the whole queue. + * + * A route reclassified read↔write turns this red instead of silently shipping either a CSWSH hole or a + * write the server 403s. + * + * The enqueued text is RAW KEYBOARD INPUT for a shell (invariant #9): it must survive the JSON body + * **verbatim** — no trim, no escape-rewrite, no Unicode normalisation, and no client-side `\r` + * (`appendEnter` is a flag the SERVER materialises). + */ +@DisplayName("W2 queue routes — shape, Origin policy, verbatim body") +class QueueRouteShapeTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val ORIGIN = "http://192.168.1.5:3000" + val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555") + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + + private val queueUrl = "$BASE/live-sessions/$ID/queue" + + private fun last(): HttpRequest = transport.recordedRequests.last() + + private fun assertGuarded(r: HttpRequest) = + assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin") + + private fun assertReadOnly(r: HttpRequest) = + assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin") + + /** Parse the recorded request body back out of JSON — proves round-trip byte-exactness without + * pinning any particular escaping strategy. */ + private fun bodyText(r: HttpRequest): String? = + Json.parseToJsonElement(r.body!!.decodeToString()).jsonObject["text"]?.jsonPrimitive?.content + + // ── GET — read-only, no Origin ──────────────────────────────────────────────────────────── + + @Test + fun `queue is a read-only GET with no Origin and no body`() = runTest { + transport.queueSuccess(url = queueUrl, body = """{"length":2,"items":["a","b"]}""".toByteArray()) + + val snapshot = client.queue(ID) + + val r = last() + assertEquals(HttpMethod.GET, r.method) + assertEquals(queueUrl, r.url) + assertReadOnly(r) + assertNull(r.body, "a GET must not carry a body") + assertEquals(2, snapshot.length) + assertEquals(listOf("a", "b"), snapshot.items) + } + + // ── POST — guarded, JSON body ───────────────────────────────────────────────────────────── + + @Test + fun `enqueueFollowup is a guarded POST with a text-appendEnter body`() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + + client.enqueueFollowup(ID, "run the tests", appendEnter = true) + + val r = last() + assertEquals(HttpMethod.POST, r.method) + assertEquals(queueUrl, r.url) + assertGuarded(r) + assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE]) + assertEquals("""{"text":"run the tests","appendEnter":true}""", r.body?.decodeToString()) + } + + @Test + fun `appendEnter false is sent verbatim and the client never appends CR itself`() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + + client.enqueueFollowup(ID, "no enter", appendEnter = false) + + assertEquals("""{"text":"no enter","appendEnter":false}""", last().body?.decodeToString()) + assertEquals("no enter", bodyText(last())) + } + + @Test + fun `control characters and CJK survive the body byte-exactly`() = runTest { + // ESC[A (up-arrow), a literal TAB, ETX (Ctrl-C), CR, a stray NUL, CJK, an emoji, and + // deliberate surrounding whitespace that must NOT be trimmed. + val raw = " \u001B[A\t\u0003 \u0000 \u4F60\u597D\uFF0C\u4E16\u754C \uD83C\uDF0F caf\u00E9\r\n " + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + + client.enqueueFollowup(ID, raw, appendEnter = false) + + assertEquals(raw, bodyText(last()), "queued prompt bytes must travel verbatim (invariant #9)") + } + + // ── DELETE — guarded, and (unlike DELETE /projects/worktree) body-less ──────────────────── + + @Test + fun `clearQueue is a guarded DELETE with NO body`() = runTest { + transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray()) + + client.clearQueue(ID) + + val r = last() + assertEquals(HttpMethod.DELETE, r.method) + assertEquals(queueUrl, r.url) + assertGuarded(r) + assertNull(r.body, "DELETE /live-sessions/:id/queue takes no body (server reads only :id)") + assertFalse(r.headers.containsKey(HeaderName.CONTENT_TYPE), "no body ⇒ no Content-Type") + } + + // ── the batch invariant ─────────────────────────────────────────────────────────────────── + + @Test + fun `only the two writes carry Origin (batch invariant)`() = runTest { + transport.queueSuccess(url = queueUrl, body = """{"length":0,"items":[]}""".toByteArray()) + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray()) + + client.queue(ID) + client.enqueueFollowup(ID, "x", appendEnter = true) + client.clearQueue(ID) + + assertReadOnly(transport.recordedRequests[0]) + assertGuarded(transport.recordedRequests[1]) + assertGuarded(transport.recordedRequests[2]) + assertNotNull(transport.recordedRequests[1].body) + } + + @Test + fun `session id is serialized lowercase in the path`() = runTest { + val upper = UUID.fromString("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE") + val url = "$BASE/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue" + transport.queueSuccess(url = url, body = """{"length":0,"items":[]}""".toByteArray()) + + client.queue(upper) + + assertTrue(last().url.endsWith("/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue")) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt new file mode 100644 index 0000000..185108d --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt @@ -0,0 +1,171 @@ +package wang.yaojia.webterm.api.routes + +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.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.GitLogResult +import wang.yaojia.webterm.api.models.GitWriteOutcome +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest + +/** + * Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the two routes the v0.6 git panel added + * after the last Android commit: `GET /projects/worktree/state` (read) and `POST /projects/git/fetch` + * (write). A route reclassified read↔write turns this red rather than silently shipping either a CSWSH + * hole or a write the server will 403. + * + * Also pins the commit-log boundary arithmetic, which the server's own commit warns "fails in the + * dangerous direction" if shortcut to "the first N rows". + */ +@DisplayName("w6 routes — shape, Origin policy, and the unpushed boundary") +class W6RouteShapeTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val ORIGIN = "http://192.168.1.5:3000" + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + + private fun last(): HttpRequest = transport.recordedRequests.last() + + // ── GET /projects/worktree/state — read-only, no Origin ────────────────────────────────────────── + + @Test + fun `worktreeState is a read-only GET with a strict-encoded path and no Origin`() = runTest { + val path = "/home/me/my repo/a+b&c" + transport.queueSuccess( + HttpMethod.GET, + "$BASE/projects/worktree/state?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c", + 200, + body = """{"path":"$path","branch":"wt","dirtyCount":2}""".toByteArray(), + ) + + val state = client.worktreeState(path) + + val r = last() + assertEquals(HttpMethod.GET, r.method) + assertFalse( + r.headers.containsKey(HeaderName.ORIGIN), + "a read must NOT stamp Origin — Origin is the CSWSH marker for state-changing routes only", + ) + assertTrue(r.url.contains("%2Bb"), "a bare + would decode to a SPACE in Express's qs parser") + assertEquals(2, state.dirtyCount) + } + + // ── POST /projects/git/fetch — guarded, byte-equal Origin, body carries only the path ──────────── + + @Test + fun `gitFetch is a guarded POST that stamps a byte-equal Origin`() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true,"lastFetchMs":42}""".toByteArray()) + + val outcome = client.gitFetch("/repo") + + val r = last() + assertEquals(HttpMethod.POST, r.method) + assertEquals( + ORIGIN, + r.headers[HeaderName.ORIGIN], + "fetch is state-changing (it moves refs/remotes and spawns a network git), so it must be guarded", + ) + assertNotNull(r.body, "the path travels in a JSON body") + val body = r.body!!.decodeToString() + assertTrue(body.contains("\"path\"")) + assertFalse( + body.contains("remote") || body.contains("refspec"), + "the remote is derived SERVER-side; a client must never be able to aim a fetch at an arbitrary URL", + ) + assertEquals(42L, (outcome as GitWriteOutcome.Ok).payload.lastFetchMs) + } + + @Test + fun `a fetch that fails leaves lastFetchMs unknown rather than claiming it refreshed`() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true}""".toByteArray()) + + val outcome = client.gitFetch("/repo") + + assertNull( + (outcome as GitWriteOutcome.Ok).payload.lastFetchMs, + "the server leaves the old value alone on failure, so the UI must keep saying stale", + ) + } + + @Test + fun `fetch surfaces its own rate limit instead of retrying`() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 429, body = """{"error":"slow down"}""".toByteArray()) + + val outcome = client.gitFetch("/repo") + + assertTrue( + outcome is GitWriteOutcome.RateLimited, + "fetch has its OWN bucket so refreshes cannot eat the budget a real push needs", + ) + } + + @Test + fun `a disabled or Origin-rejected fetch surfaces the server's safe message`() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 403, body = """{"error":"git ops disabled"}""".toByteArray()) + + val outcome = client.gitFetch("/repo") + + // 403 is overloaded — kill-switch AND Origin failure both return it — so the message is + // surfaced rather than guessed at with a typed variant. + assertEquals("git ops disabled", (outcome as GitWriteOutcome.Rejected).message) + } + + // ── the unpushed boundary (w6/G4) ──────────────────────────────────────────────────────────────── + + @Test + @DisplayName("the boundary sits after the LAST unpushed commit, even when they interleave") + fun boundaryHandlesInterleaving() { + // A backdated merge puts an unpushed commit BELOW a pushed one, because git log is date-ordered. + // "The first N rows" would mark the wrong set and call an unpushed commit pushed. + val log = GitLogResult( + commits = listOf( + commit("aaa", unpushed = true), + commit("bbb", unpushed = false), + commit("ccc", unpushed = true), + commit("ddd", unpushed = false), + ), + upstream = "origin/main", + ) + assertEquals(2, log.upstreamBoundaryIndex, "after index 2 — the last unpushed one") + assertEquals(2, log.unpushedCount) + } + + @Test + @DisplayName("no upstream means NO boundary may be drawn at all") + fun noUpstreamNoBoundary() { + val log = GitLogResult(commits = listOf(commit("aaa", unpushed = true)), upstream = null) + assertNull( + log.upstreamBoundaryIndex, + "null rather than -1, so a caller cannot mistake 'no boundary' for 'boundary before everything'", + ) + } + + @Test + @DisplayName("nothing unpushed means no boundary") + fun nothingUnpushed() { + val log = GitLogResult(commits = listOf(commit("aaa", unpushed = false)), upstream = "origin/main") + assertNull(log.upstreamBoundaryIndex) + assertEquals(0, log.unpushedCount) + } + + @Test + @DisplayName("an unknown unpushed flag is not counted as pushed") + fun unknownIsNotPushed() { + val log = GitLogResult(commits = listOf(commit("aaa", unpushed = null)), upstream = "origin/main") + assertEquals(0, log.unpushedCount, "unknown is not 'unpushed'…") + assertNull(log.upstreamBoundaryIndex, "…and it must not invent a boundary either") + } + + private fun commit(hash: String, unpushed: Boolean?) = + wang.yaojia.webterm.api.models.CommitLogEntry(hash = hash, at = 1L, subject = "s", unpushed = unpushed) +} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8010a60..b244242 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -10,6 +10,8 @@ // com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android // ───────────────────────────────────────────────────────────────────────────── +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) @@ -17,6 +19,83 @@ plugins { alias(libs.plugins.hilt) } +// ───────────────────────────────────────────────────────────────────────────── +// Release signing credentials — NEVER committed. +// +// Looked up in this order, first non-blank wins: +// 1. `android/keystore.properties` (the conventional path; see keystore.properties.example) +// 2. `android/local.properties` (already gitignored — the zero-setup local option) +// 3. `WEBTERM_RELEASE_*` env vars (CI) +// +// DEGRADATION CONTRACT: absent credentials leave debug (and the `benchmark` variant, +// which signs with the debug key) fully working, and make the RELEASE package task fail +// with RELEASE_SIGNING_HELP — never a silent `app-release-unsigned.apk`. +// ───────────────────────────────────────────────────────────────────────────── +val signingPropertyFiles = listOf("keystore.properties", "local.properties") + +val signingProperties = Properties().apply { + // Later files must not override earlier ones, so load in reverse and let the + // higher-priority file overwrite. + signingPropertyFiles.reversed() + .map(rootProject::file) + .filter(File::exists) + .forEach { file -> file.inputStream().use(::load) } +} + +fun signingCredential(propertyKey: String, envKey: String): String? = + (signingProperties.getProperty(propertyKey) ?: System.getenv(envKey))?.takeIf(String::isNotBlank) + +val releaseStoreFile = signingCredential("webterm.release.storeFile", "WEBTERM_RELEASE_STORE_FILE") +val releaseStorePassword = signingCredential("webterm.release.storePassword", "WEBTERM_RELEASE_STORE_PASSWORD") +val releaseKeyAlias = signingCredential("webterm.release.keyAlias", "WEBTERM_RELEASE_KEY_ALIAS") +val releaseKeyPassword = signingCredential("webterm.release.keyPassword", "WEBTERM_RELEASE_KEY_PASSWORD") + +// `storeFile` may be absolute or relative to `android/` — rootProject.file handles both. +val releaseKeystore = releaseStoreFile?.let(rootProject::file) + +val hasAllReleaseCredentials = + releaseStoreFile != null && + releaseStorePassword != null && + releaseKeyAlias != null && + releaseKeyPassword != null + +val isReleaseSigningConfigured = hasAllReleaseCredentials && releaseKeystore?.exists() == true + +// Two distinct failures deserve two distinct messages: "you have not set this up" and +// "you set it up but pointed it at a file that isn't there" have completely different fixes. +val MISSING_KEYSTORE_HELP = """ + |:app release signing credentials were found, but the keystore file does not exist: + | ${releaseKeystore?.absolutePath} + | + |Fix `webterm.release.storeFile` (absolute, or relative to the `android/` directory), or + |point WEBTERM_RELEASE_STORE_FILE at the real file. Nothing was signed, and no unsigned + |APK was emitted. +""".trimMargin() + +val UNCONFIGURED_SIGNING_HELP = """ + |:app release signing is NOT configured — refusing to emit an unsigned release artifact. + | + |Provide the four credentials in ONE of these places (first match wins): + | 1. android/keystore.properties — copy android/keystore.properties.example. + | MAKE SURE `keystore.properties` is in android/.gitignore first. + | 2. android/local.properties — already gitignored; zero extra setup. + | 3. env: WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI) + | + |Keys (property form): + | webterm.release.storeFile= + | webterm.release.storePassword=<...> + | webterm.release.keyAlias=<...> + | webterm.release.keyPassword=<...> + | + |No keystore yet? Generate one OUTSIDE the repo: + | keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \ + | -alias webterm -keyalg RSA -keysize 4096 -validity 10000 + |Then record its SHA-256 in the App Links assetlinks.json (plan §8). + | + |Unaffected by this failure: `:app:assembleDebug`, `:app:assembleBenchmark`, + |`:app:assembleDebugAndroidTest`, every unit test, and R8 itself (which already ran). +""".trimMargin() + android { namespace = "wang.yaojia.webterm" // compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for @@ -28,24 +107,87 @@ android { applicationId = "wang.yaojia.webterm" minSdk = 29 targetSdk = 35 + // ── Versioning policy ──────────────────────────────────────────────── + // versionCode: a plain monotonic counter, +1 for EVERY artifact handed to + // anyone (Play track, internal APK, benchmark run that gets archived). + // Never derived from versionName — decoupling them is what lets a hotfix + // ship without renumbering. This is still 1 because no artifact has ever + // left this machine; the first distributed build takes 2. + // versionName: `-alphaNN`. The Android client tracks the server's + // 0.1.x line (root package.json is 0.1.0). `-alpha01` is the honest state: + // the P0+P1 surface from ANDROID_CLIENT_PLAN §1 is implemented and JVM-tested, + // but NOTHING in DEVICE_QA_CHECKLIST.md has been signed off on real hardware. + // Drop to `-beta01` when the checklist's A34/A35 blocks pass on a device, and + // to plain `0.1.0` when the whole checklist is ticked. versionCode = 1 - versionName = "0.1.0" + versionName = "0.1.0-alpha01" + + // Hilt instrumented testing REQUIRES a custom runner: AndroidJUnitRunner is the + // only hook that can swap the app-under-test's Application for the generated + // `HiltTestApplication` (the androidTest manifest cannot — it is merged into the + // TEST apk, not into the app under test). See android/README.md → "Instrumented tests". + testInstrumentationRunner = "wang.yaojia.webterm.HiltTestRunner" } buildFeatures { compose = true } + signingConfigs { + // Populated ONLY when credentials were found; an empty config would make AGP + // fall back to emitting `app-release-unsigned.apk`, which is exactly the silent + // failure this block exists to prevent (the release buildType leaves + // `signingConfig` null instead, and the guard task below fails the build). + if (isReleaseSigningConfigured) { + create("release") { + storeFile = rootProject.file(releaseStoreFile!!) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + // v1 (JAR signing) is only needed below API 24 and is the weakest scheme — + // off. v2/v3 are left enabled and apksigner picks what the minSdk range + // actually requires: verified output for minSdk 29 is v1=false, v2=false, + // v3=true (every device that can install this understands v3, so v2 is + // redundant). Do not read v2=false as a misconfiguration. + enableV1Signing = false + enableV2Signing = true + enableV3Signing = true + } + } + } + buildTypes { debug { // No applicationId suffix — keep it stable for deep-link testing (A32). } release { - isMinifyEnabled = false + isMinifyEnabled = true + // Resource shrinking requires minification; it is what strips the unused + // CameraX/ML-Kit/Firebase resources the 56 MB unminified APK carried. + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + signingConfig = signingConfigs.findByName("release") + } + // `benchmark` — what :macrobenchmark (A35) measures against. It must match the + // shipping variant in everything that affects performance (non-debuggable, R8'd, + // resource-shrunk) while needing NO release keystore, so it signs with the debug + // key. It is therefore also the variant that proves the R8 pipeline end-to-end + // (`:app:assembleBenchmark` produces a real, installable, minified APK). + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + // Library modules only publish debug/release; resolve `benchmark` to release. + matchingFallbacks += listOf("release") + isDebuggable = false + // Injects into the merged manifest, which + // macrobenchmark needs to read traces on API 29-30 (implicit from API 31). + // Done here BECAUSE AndroidManifest.xml must not carry a benchmark-only tag. + isProfileable = true + isMinifyEnabled = true + isShrinkResources = true } } @@ -114,9 +256,41 @@ dependencies { testImplementation(libs.bundles.unit.test) testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6) testRuntimeOnly(libs.junit.platform.launcher) + + // ── Instrumented tests (androidTest — device/emulator only; plan §7) ────────── + // Homes A34 (E2E against a real `npm start` host) and the Espresso/Compose half of + // A35. JUnit4 only: AndroidJUnitRunner cannot drive the JUnit5 platform. + androidTestImplementation(libs.bundles.android.instrumented.test) + // Compose UI assertions (gate banner / plan sheet / reconnect-banner precedence / + // session rows / continue-last banner). BOM-managed, same BOM as main. + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + // Supplies the empty host Activity that `createComposeRule()` launches into. Must be + // debugImplementation (it contributes a manifest entry to the app under test). + debugImplementation(libs.androidx.compose.ui.test.manifest) + // Hilt instrumented testing: HiltAndroidRule + the generated HiltTestApplication. + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.compiler) } // Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules. +// Instrumented (androidTest) tasks are NOT of type Test, so they keep JUnit4. tasks.withType().configureEach { useJUnitPlatform() } + +// ───────────────────────────────────────────────────────────────────────────── +// Release-signing guard. +// +// Hooked as a doFirst on the PACKAGE task (not as a dependency) on purpose: packaging +// runs after dexing, so R8/resource-shrinking have fully completed and been reported by +// the time this fires. An unconfigured machine therefore still gets a real, verifiable +// R8 run — it just cannot produce a distributable artifact. +// ───────────────────────────────────────────────────────────────────────────── +if (!isReleaseSigningConfigured) { + val help = if (hasAllReleaseCredentials) MISSING_KEYSTORE_HELP else UNCONFIGURED_SIGNING_HELP + tasks.matching { it.name == "packageRelease" || it.name == "packageReleaseBundle" } + .configureEach { + doFirst { throw GradleException(help) } + } +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 45f9aa2..acb163c 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,4 +1,117 @@ -# 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. +# ───────────────────────────────────────────────────────────────────────────── +# WebTerm :app — R8 keep rules (release + benchmark; both isMinifyEnabled = true). +# +# DISCIPLINE: every rule below is justified, and rules that turned out to be +# UNNECESSARY are recorded as such rather than added "just in case". A superfluous +# -keep silently costs size and hides real reachability problems; a MISSING one +# crashes only in production. Both were checked against the merged configuration +# R8 actually used, which is dumped to: +# app/build/outputs/mapping//configuration.txt +# Read that file before adding anything here — most of our stack already ships its +# own consumer rules, and duplicating them is how this file rots. +# +# ALREADY COVERED BY EMBEDDED CONSUMER RULES — do NOT re-declare here: +# · kotlinx.serialization — kotlinx-serialization-core-jvm ships +# META-INF/com.android.tools/r8/kotlinx-serialization-{common,r8}.pro, which keeps +# @Serializable classes' `Companion` fields, `serializer(...)`, object `INSTANCE`, +# the `$$serializer.descriptor` field, and RuntimeVisibleAnnotations (the R8 +# full-mode fix). Verified present in 1.9.0. Our @Serializable models live in +# :wire-protocol / :api-client; the rules are global, so they are covered. +# · Hilt / Dagger — hilt-android + dagger AARs ship proguard.txt; all injection is +# generated code with compile-time references, no runtime reflection to preserve. +# · Tink — tink-android ships META-INF/proguard/protobuf.pro, keeping on +# subclasses of the shaded protobuf GeneratedMessageLite (the reflective lite +# runtime). Tink's key managers are registered explicitly by AeadConfig.register(), +# not discovered reflectively, so nothing else needs keeping. +# · FCM — firebase-messaging ships consumer rules, AND `.push.FcmService`, +# `.push.DenyBroadcastReceiver`, `.push.AllowTrampolineActivity`, `.MainActivity` +# and `.WebTermApp` are all declared in AndroidManifest.xml, for which AGP +# auto-generates keep rules (build/intermediates/aapt_proguard_file/.../aapt_rules.txt). +# Manifest-declared components therefore need no rule here — ever. +# · OkHttp / Okio, Compose, CameraX, ML Kit barcode, DataStore, navigation-compose, +# biometric — all ship their own consumer rules. +# · JNI — the default proguard-android-optimize.txt already contains +# `-keepclasseswithmembernames class * { native ; }`, which covers the +# Termux emulator's native entry points even though we never fork a subprocess. +# ───────────────────────────────────────────────────────────────────────────── + + +# ── Termux terminal emulator + view (the ONE thing that genuinely needs a rule) ── +# +# Why this is not "just in case": +# 1. The JitPack artifacts (com.github.termux.termux-app:terminal-{emulator,view}, +# v0.118.0) ship NO consumer rules at all — nobody upstream has reasoned about R8 +# for these modules, because Termux itself ships unminified. +# 2. :terminal-view deliberately binds to NON-API internals of the pinned version +# (plan §6.1 — TerminalView is `public final`, so we fork rather than subclass): +# · RemoteTerminalHostView writes and reads the public field +# `com.termux.view.TerminalView.mEmulator` directly (installEmulator / +# the TerminalViewClient callbacks / computeVerticalScrollRange). +# · TerminalResizeDriver calls `TerminalRenderer.getFontWidth()` and +# `getFontLineSpacing()` — public accessors verified with `javap -p`, but not +# part of any documented API surface. +# · TerminalScrollGesture reads `mEmulator` and drives KeyHandler. +# Field/method RENAMING alone would survive that (R8 rewrites both sides), but R8 +# full mode also does class merging, member inlining and access relaxation across +# a library whose invariants we are already stretching. A wrong outcome here is a +# crash in the app's single core surface — the terminal — visible ONLY in release. +# 3. The cost is bounded and small: two Apache-2.0 modules, ~120 KB of classes. +# +# So: keep them whole. Revisit only with a device-verified minified build in hand. +-keep class com.termux.terminal.** { *; } +-keep class com.termux.view.** { *; } + + +# ── ML Kit / Firebase component discovery (reflective no-arg constructors) ─────── +# +# OBSERVED, not speculative. Installing the minified `benchmark` APK on an API-35 +# emulator and cold-starting it produced, with no rule here: +# +# W ComponentDiscovery: Invalid component registrar. +# Could not instantiate com.google.mlkit.common.internal.CommonComponentRegistrar +# ... at com.google.mlkit.common.internal.MlKitInitProvider.onCreate +# Caused by: java.lang.NoSuchMethodException: +# com.google.mlkit.common.internal.CommonComponentRegistrar. [] +# (and the same for com.google.mlkit.vision.common.internal.VisionCommonRegistrar) +# +# Firebase's ComponentDiscovery instantiates registrars named in by +# `getDeclaredConstructor()`. The class names survive, but R8 removed the no-arg +# constructors because nothing in the app calls them. The failure is a WARNING, not a +# crash: the app starts fine and the ML Kit component graph is simply never registered, +# so **QR pairing (A19 barcode scanning) silently stops working in minified builds +# only**. Exactly the release-only breakage this file has to prevent. +# +# `();` (no access modifier) matches any visibility, which is what +# getDeclaredConstructor needs. Scoped to registrars, so it keeps ~a dozen tiny classes. +-keep class * implements com.google.firebase.components.ComponentRegistrar { + (); +} + + +# ── javax.naming does not exist on Android ────────────────────────────────────── +# +# R8 refuses to run without these two, so they are load-bearing for ANY minified build. +# They are NOT a benign suppression — read this before deleting them: +# +# :client-tls CertificateSummary.kt uses `javax.naming.ldap.LdapName` to pull the CN +# out of an RFC2253 DN. That package is JDK-only; it is absent from android.jar and +# from the ART bootclasspath. So on a real device `CertificateSummaryReader.commonName` +# throws NoClassDefFoundError, which is an Error and therefore slips straight through +# that function's `catch (_: Exception)` guard. +# +# The module is pure-JVM and its unit tests run on a real JDK where LdapName DOES +# exist, which is why the green JVM suite never caught it. Tracked as a handoff to +# :client-tls's owner (hand-parse the DN, or catch Throwable). This -dontwarn only +# stops R8 from failing the build over an unresolvable reference; it does not and +# cannot fix the device behaviour. +-dontwarn javax.naming.ldap.LdapName +-dontwarn javax.naming.ldap.Rdn + + +# ── Crash triage ──────────────────────────────────────────────────────────────── +# Keep line numbers so release stack traces are usable, and rename the source-file +# attribute to a single constant so no original filenames leak in the APK. Retrace +# release traces with app/build/outputs/mapping/release/mapping.txt (ARCHIVE IT with +# every distributed artifact — without it a crash report is unreadable). +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt new file mode 100644 index 0000000..9e3f1d4 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt @@ -0,0 +1,28 @@ +package wang.yaojia.webterm + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +/** + * The instrumentation runner named by `:app`'s `testInstrumentationRunner`. + * + * A custom runner is **required**, not stylistic: [newApplication] is the only hook that can replace the + * app-under-test's [Application] with the generated [HiltTestApplication]. Setting `android:name` in the + * androidTest manifest cannot do it — that manifest is merged into the *test* APK, not into the app under + * test, so the real [WebTermApp] would still be installed and `@HiltAndroidTest` injection would fail. + * + * `assembleDebugAndroidTest` builds fine without this class (the runner name is only a manifest value); + * an actual instrumentation *run* would fail with `ClassNotFoundException`. See `android/README.md` → + * "Instrumented tests". + * + * Note that installing [HiltTestApplication] replaces [WebTermApp], so anything [WebTermApp] does in + * `onCreate` (push registration, notification channels) does NOT happen under instrumentation. That is + * deliberate — a test must opt into those via Hilt test modules rather than inherit them. + */ +public class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application = + super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/CswshDefenceE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/CswshDefenceE2eTest.kt new file mode 100644 index 0000000..537e4a9 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/CswshDefenceE2eTest.kt @@ -0,0 +1,106 @@ +package wang.yaojia.webterm.e2e + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.ServerMessage + +/** + * **F9 — bad `Origin` on the WS upgrade is rejected. The one non-skippable defence.** + * + * This app hands a full shell to anyone who can reach the port, and Origin validation on the WebSocket + * handshake is the single thing standing between that shell and a malicious page open in the user's + * browser (Cross-Site WebSocket Hijacking). TECH_DOC §7 and plan §8 both call it non-negotiable, and it is + * the only security control in this codebase that cannot be compensated for elsewhere. + * + * A unit test cannot verify it: the check lives in the *server's* upgrade handler, so the only honest proof + * is a real socket carrying a real foreign `Origin` and being refused. That is what this class does, from + * the device, against the real server. It also pins the two neighbouring cases the check must not get + * wrong — a MISSING Origin (default-deny) and the correct Origin (must still work, or the "defence" is + * just a broken client). + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class CswshDefenceE2eTest { + + /** + * The attack: a page on another origin opens `ws://:3000/term`. The browser stamps ITS origin, + * which is not in the server's allow-list, and the handshake must be refused with 401 — before any + * frame can be exchanged. + */ + @Test + fun aForeignOriginIsRefusedOnTheWsUpgrade() { + val host = WebTermTestHost.require() + val socket = E2eSocket.dial(host, origin = FOREIGN_ORIGIN) + try { + val failure = socket.failureOrNull() + + assertNotNull( + "the handshake must FAIL with a foreign Origin — it opened instead, which is a CSWSH hole", + failure, + ) + assertEquals( + "the refusal must be a 401 (src/server.ts writes it before destroying the socket)", + HTTP_UNAUTHORIZED, + socket.handshakeStatusOrNull(), + ) + } finally { + socket.cancel() + } + } + + /** + * Default-deny: a non-browser client that sends no `Origin` at all must also be refused. `curl` and + * scripts land here, and `isOriginAllowed(undefined, …)` returning false is the single central policy + * point — an allow-if-absent would make the whole check trivially bypassable. + */ + @Test + fun aMissingOriginIsRefusedOnTheWsUpgrade() { + val host = WebTermTestHost.require() + val socket = E2eSocket.dial(host, origin = null) + try { + assertNotNull( + "an absent Origin must be refused (default-deny), not treated as trusted", + socket.failureOrNull(), + ) + assertEquals(HTTP_UNAUTHORIZED, socket.handshakeStatusOrNull()) + } finally { + socket.cancel() + } + } + + /** + * The control: the SAME dial with the endpoint-derived `Origin` (byte-equal to what + * `HostEndpoint.originHeader` produces, and to what `src/http/origin.ts` expects) must open and attach. + * Without this, the two refusals above would also be satisfied by a server that rejects everything. + */ + @Test + fun theEndpointDerivedOriginIsAccepted() { + val host = WebTermTestHost.require() + val socket = E2eSocket.dial(host) + var sessionId: String? = null + try { + assertTrue( + "the production Origin must be accepted, got failure=${socket.failureOrNull()}", + socket.failureOrNull() == null, + ) + socket.send(ClientMessage.Attach(sessionId = null)) + sessionId = (socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached) + .sessionId + } finally { + socket.close() + sessionId?.let { runCatching { host.delete("/live-sessions/$it") } } + } + } + + private companion object { + /** Deliberately not a real host: the point is that it is not in the server's allow-list. */ + const val FOREIGN_ORIGIN = "http://evil.example" + const val HTTP_UNAUTHORIZED = 401 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/E2eSocket.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/E2eSocket.kt new file mode 100644 index 0000000..b174b90 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/E2eSocket.kt @@ -0,0 +1,185 @@ +package wang.yaojia.webterm.e2e + +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.MessageCodec +import wang.yaojia.webterm.wire.ServerMessage +import wang.yaojia.webterm.wire.WireConstants +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * One live WS connection to a real WebTerm server, driven synchronously so an E2E test reads as a + * transcript. + * + * It deliberately does NOT reuse `:transport-okhttp`'s [wang.yaojia.webterm.transport.OkHttpTermTransport]: + * that class stamps `Origin` from the endpoint by construction, which is exactly the thing the F9 test has + * to be able to falsify. So the upgrade request is built here, and `Origin` is a parameter. Everything + * else that matters IS production code — [MessageCodec] encodes and decodes every frame, so these tests + * are a check of the frozen wire contract against the real server, not of a parallel test codec. + */ +internal class E2eSocket private constructor( + private val opened: CountDownLatch, + private val closed: CountDownLatch, +) : WebSocketListener() { + + private val frames = LinkedBlockingQueue() + + /** + * Every `output` payload, accumulated as frames ARRIVE rather than as a test pulls them. + * + * This is not an optimisation, it is a correctness requirement. On a re-attach the server replays the + * ring buffer INSIDE `manager.handleAttach`, i.e. it puts the replay `output` frame on the wire BEFORE + * the `attached` confirmation. A reader that scanned the queue for `attached` first would consume and + * discard the replay on the way past — which is exactly how the first run of the replay test reported + * "0 chars" against a server that had sent hundreds of kilobytes. + */ + private val output = StringBuilder() + private val lastFrameAt = java.util.concurrent.atomic.AtomicLong(System.nanoTime()) + private val socket = AtomicReference(null) + private val failure = AtomicReference(null) + private val handshakeStatus = AtomicReference(null) + private val closeCode = AtomicReference(null) + + override fun onOpen(webSocket: WebSocket, response: Response) { + handshakeStatus.set(response.code) + socket.set(webSocket) + opened.countDown() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + lastFrameAt.set(System.nanoTime()) + frames.put(text) + (MessageCodec.decodeServer(text) as? ServerMessage.Output)?.let { frame -> + synchronized(output) { output.append(frame.data) } + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + handshakeStatus.set(response?.code) + failure.set(t) + opened.countDown() + closed.countDown() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + closeCode.set(code) + closed.countDown() + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + closeCode.set(code) + webSocket.close(code, reason) + } + + // ── driving ────────────────────────────────────────────────────────────────────────────────── + + fun send(message: ClientMessage) { + val live = socket.get() + assertNotNull("cannot send $message — the WS never opened", live) + assertTrue("the WS refused to enqueue $message", live!!.send(MessageCodec.encode(message))) + } + + /** The next decodable server frame matching [predicate], or fail with what actually arrived. */ + fun awaitFrame(what: String, timeoutMs: Long = FRAME_TIMEOUT_MS, predicate: (ServerMessage) -> Boolean): ServerMessage { + val seen = ArrayList() + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs) + while (System.nanoTime() < deadline) { + val remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()).coerceAtLeast(1) + val raw = frames.poll(remaining, TimeUnit.MILLISECONDS) ?: break + seen += raw.take(RAW_FRAME_LOG_CHARS) + val decoded = MessageCodec.decodeServer(raw) + if (decoded != null && predicate(decoded)) return decoded + } + throw AssertionError( + "no frame matching \"$what\" within $timeoutMs ms. Frames seen: $seen. " + + "failure=${failure.get()} closeCode=${closeCode.get()}", + ) + } + + /** + * Wait for the wire to go quiet for [quietMs], then take everything `output` has carried since the last + * call. Bounded by [DRAIN_CEILING_MS] so a session that never stops talking cannot hang the suite. + */ + fun drainOutput(quietMs: Long = QUIET_MS): String { + val ceiling = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DRAIN_CEILING_MS) + while (System.nanoTime() < ceiling) { + val idleMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastFrameAt.get()) + if (idleMs >= quietMs) break + Thread.sleep(DRAIN_POLL_MS) + } + return synchronized(output) { + val text = output.toString() + output.setLength(0) + text + } + } + + fun awaitClosed(timeoutMs: Long = FRAME_TIMEOUT_MS): Boolean = + closed.await(timeoutMs, TimeUnit.MILLISECONDS) + + fun failureOrNull(): Throwable? = failure.get() + + fun handshakeStatusOrNull(): Int? = handshakeStatus.get() + + fun close() { + socket.get()?.close(NORMAL_CLOSURE, null) + } + + fun cancel() { + socket.get()?.cancel() + } + + companion object { + const val NORMAL_CLOSURE: Int = 1000 + private const val OPEN_TIMEOUT_MS = 8_000L + private const val FRAME_TIMEOUT_MS = 10_000L + private const val QUIET_MS = 700L + private const val DRAIN_CEILING_MS = 30_000L + private const val DRAIN_POLL_MS = 50L + private const val RAW_FRAME_LOG_CHARS = 200 + + /** + * Dial the host's `/term`. Returns as soon as the handshake resolves either way, so a rejection + * (the F9 case) is observable rather than a hang. + * + * @param origin what to put in the `Origin` header. Defaults to the endpoint-derived value, i.e. + * exactly what production sends; a foreign value is how the CSWSH defence is falsified. + */ + fun dial( + host: WebTermTestHost.ReachableHost, + origin: String? = host.endpoint.originHeader, + ): E2eSocket { + val opened = CountDownLatch(1) + val listener = E2eSocket(opened, CountDownLatch(1)) + val request = Request.Builder() + .url(host.endpoint.wsUrl) + .apply { origin?.let { header("Origin", it) } } + .build() + host.client.newWebSocket(request, listener) + assertTrue( + "the WS handshake to ${host.endpoint.wsUrl} neither opened nor failed within $OPEN_TIMEOUT_MS ms", + opened.await(OPEN_TIMEOUT_MS, TimeUnit.MILLISECONDS), + ) + return listener + } + + /** `attach` must be the FIRST frame on every (re)connect (plan §4.1). */ + fun attach(host: WebTermTestHost.ReachableHost, sessionId: String?, cwd: String? = null): Pair { + val socket = dial(host) + socket.send(ClientMessage.Attach(sessionId = sessionId, cwd = cwd)) + val attached = socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached + return socket to attached.sessionId + } + + /** The soft-reset prefix the server puts in front of a ring-buffer replay — never stripped (§4.1). */ + const val REPLAY_PREFIX: String = WireConstants.REPLAY_SOFT_RESET_PREFIX + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGate.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGate.kt new file mode 100644 index 0000000..57659d1 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGate.kt @@ -0,0 +1,115 @@ +package wang.yaojia.webterm.e2e + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.junit.Assert.assertTrue +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * A permission gate held open on a real server, created exactly the way Claude Code creates one. + * + * `POST /hook/permission` is the hook side-channel: the server holds the HTTP request open until a decision + * arrives (or it times out), then writes the decision JSON as the response — which the hook's `curl` feeds + * back to Claude on stdout. So the *held request itself* is the observable for "is the gate still held", and + * that is the only way to tell a real resolution from a UI that merely stopped showing the banner. + * + * Two server preconditions are worth knowing before reading a failure here: + * - **Loopback only.** `POST /hook/permission` 403s a non-loopback peer. From an emulator this is satisfied + * for free: `10.0.2.2` is forwarded to the host's loopback, so the server sees `127.0.0.1`. Over a LAN + * address it will 403, and [hold] says so rather than timing out mysteriously. + * - **Someone must be watching.** The server only HOLDS when the session has an attached client or a + * registered push target; otherwise it answers `{}` immediately and lets Claude prompt locally. The + * caller therefore attaches first. + */ +internal class HeldGate private constructor( + private val call: Call, + private val completed: CountDownLatch, + private val body: AtomicReference, + private val failure: AtomicReference, +) { + /** Whether the server has already answered — i.e. the gate is no longer held. */ + fun hasResponded(): Boolean = completed.count == 0L + + /** The decision body the server finally wrote, or fail if the hold never resolved. */ + fun awaitResponse(timeoutMs: Long = RESPONSE_TIMEOUT_MS): String { + val done = completed.await(timeoutMs, TimeUnit.MILLISECONDS) + if (!done) { + call.cancel() + throw AssertionError("the held /hook/permission request never resolved within $timeoutMs ms") + } + failure.get()?.let { throw AssertionError("the held /hook/permission request failed: $it") } + return body.get() ?: "" + } + + companion object { + private const val RESPONSE_TIMEOUT_MS = 15_000L + private const val HOLD_SETTLE_MS = 600L + private val JSON = "application/json; charset=utf-8".toMediaType() + + /** The tool name the server maps to a plain two-way `tool` gate (anything but `ExitPlanMode`). */ + private const val TOOL_NAME = "Bash" + + /** + * Start holding a gate for [sessionId]. Returns once the request is in flight and the server has + * had time to either hold it or refuse it — and fails loudly if it refused, because a test that + * proceeded from here would be asserting against a gate that does not exist. + */ + fun hold(host: WebTermTestHost.ReachableHost, sessionId: String): HeldGate { + val completed = CountDownLatch(1) + val body = AtomicReference(null) + val failure = AtomicReference(null) + val request = Request.Builder() + .url(host.endpoint.baseUrl + "/hook/permission") + .header("x-webterm-session", sessionId) + // `tool_input` is untrusted by contract; a harmless command keeps the derived preview real. + .post( + """{"tool_name":"$TOOL_NAME","tool_input":{"command":"echo webterm-e2e-gate"}}""" + .toByteArray() + .toRequestBody(JSON), + ) + .build() + // Its own client: the hold occupies the connection for as long as the gate lives, and the read + // timeout has to outlast that, which must not be imposed on the rest of the suite. + val holdingClient = host.client.newBuilder() + .readTimeout(RESPONSE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS) + .build() + val call = holdingClient.newCall(request) + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + failure.set(e) + completed.countDown() + } + + override fun onResponse(call: Call, response: Response) { + response.use { body.set(it.body?.string() ?: "") } + completed.countDown() + } + }) + + // Give the server a moment to register the hold (or refuse it outright). + Thread.sleep(HOLD_SETTLE_MS) + val gate = HeldGate(call, completed, body, failure) + if (gate.hasResponded()) { + val answered = body.get().orEmpty().replace(" ", "") + assertTrue( + """ + |POST /hook/permission did not HOLD, so there is no gate to test. It answered immediately + |with "$answered" (failure=${failure.get()}). The two reasons this happens: + | 1. the peer was not loopback (403) — use the emulator's http://10.0.2.2: alias, + | which the host sees as 127.0.0.1, rather than a LAN address; + | 2. nothing was watching the session ({}) — attach a client before holding. + """.trimMargin(), + false, + ) + } + return gate + } + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGateE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGateE2eTest.kt new file mode 100644 index 0000000..a355858 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGateE2eTest.kt @@ -0,0 +1,159 @@ +package wang.yaojia.webterm.e2e + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ApproveMode +import wang.yaojia.webterm.wire.ClaudeStatus +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.GateKind +import wang.yaojia.webterm.wire.ServerMessage +import java.util.UUID + +/** + * **A34 — a held permission gate, and what may and may not resolve it.** + * + * A gate is created the way Claude Code creates one: `POST /hook/permission` from the host's own loopback + * (which is what the emulator's `10.0.2.2` alias resolves to on the host side, so the loopback-only guard + * is satisfied without any special setup). The server holds that HTTP request open until a decision + * arrives, and pushes a `status` frame with `pending: true` to every attached client — which is the frame + * the cockpit's gate card renders. + * + * ### An honest boundary: the `/hook/decision` HAPPY path is not automatable + * `/hook/decision` requires the per-decision capability token, and that token leaves the server **only** + * inside a push payload (`pushService.notify(session, 'needs-input', token)`) — never over the WS, never in + * `/live-sessions`. That is deliberate payload minimisation (plan §8), and it means no automated client can + * legitimately obtain it. So this class proves the parts that ARE provable and does not fake the rest: + * + * - a gate really is held, and the held-gate `status` frame really reaches an attached device; + * - `/hook/decision` **refuses** a well-formed but wrong token (403) and a malformed body (400), and the + * gate is still held afterwards — the security half, and the half a bug would most likely break; + * - the gate resolves through the in-app path (`approve` on the WS), observed by the held + * `POST /hook/permission` request finally returning. + * + * The remaining step — a real notification-action POST with a real token — stays on the device-QA checklist + * under Push, where it belongs, because it needs a real FCM delivery. It is NOT quietly asserted here. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class HeldGateE2eTest { + + /** + * A wrong capability token must be refused and must leave the gate held. This is the SEC-C1/M1 + * contract: the token is the whole authorisation for a decision made from a lock screen, so a + * mismatched, stale or absent one has to be a hard 403. + */ + @Test + fun hookDecisionRefusesAWrongTokenAndLeavesTheGateHeld() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + val gate = HeldGate.hold(host, sessionId) + try { + // The gate must actually be held before the refusal means anything. + val status = socket.awaitFrame("the held-gate status frame") { + it is ServerMessage.Status && it.pending + } as ServerMessage.Status + assertEquals("a plain tool gate must be reported as such", GateKind.TOOL, status.gate) + assertEquals(ClaudeStatus.WAITING, status.status) + + val wrongToken = UUID.randomUUID().toString() + val (refused, _) = host.postJson( + "/hook/decision", + """{"sessionId":"$sessionId","decision":"allow","token":"$wrongToken"}""", + origin = host.endpoint.originHeader, + ) + assertEquals( + "a mismatched capability token must 403 — it is the only thing authorising a remote decision", + WebTermTestHost.ReachableHost.FORBIDDEN, + refused, + ) + + val (malformed, _) = host.postJson( + "/hook/decision", + """{"sessionId":"$sessionId","decision":"maybe","token":"$wrongToken"}""", + origin = host.endpoint.originHeader, + ) + assertEquals( + "an unknown decision verb must 400, never be coerced into allow", + HTTP_BAD_REQUEST, + malformed, + ) + + assertTrue( + "the refusals must not have resolved the gate — the hook request must still be held", + !gate.hasResponded(), + ) + } finally { + socket.send(ClientMessage.Reject) // release the hold so the server is left clean + gate.awaitResponse() + socket.close() + runCatching { host.delete("/live-sessions/$sessionId") } + } + } + + /** + * `/hook/decision` is a GUARDED route, so a foreign `Origin` must 403 before the token is even looked + * at. Otherwise a malicious page could spend a token it somehow observed. + */ + @Test + fun hookDecisionRefusesAForeignOrigin() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + try { + val (status, _) = host.postJson( + "/hook/decision", + """{"sessionId":"$sessionId","decision":"allow","token":"${UUID.randomUUID()}"}""", + origin = FOREIGN_ORIGIN, + ) + + assertEquals( + "the Origin guard must run before the token check on /hook/decision", + WebTermTestHost.ReachableHost.FORBIDDEN, + status, + ) + } finally { + socket.close() + runCatching { host.delete("/live-sessions/$sessionId") } + } + } + + /** + * The in-app resolution path, end to end: the attached device sends `approve` (with the mode as a + * TOP-LEVEL key, per plan §4.1) and the held `POST /hook/permission` request returns — which is the + * server telling Claude Code to proceed. Observing that return is the only proof the hold was really + * released rather than merely hidden in the UI. + */ + @Test + fun approveOnTheWireResolvesTheHeldGate() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + val gate = HeldGate.hold(host, sessionId) + try { + socket.awaitFrame("the held-gate status frame") { it is ServerMessage.Status && it.pending } + assertTrue("the hook request must still be held before we decide", !gate.hasResponded()) + + socket.send(ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS)) + + val body = gate.awaitResponse() + assertTrue( + "the held hook request must return a decision body, got: $body", + body.isNotEmpty() && body.trimStart().startsWith("{"), + ) + assertTrue( + "an approval must not come back as a plain empty object (that is the timeout fallback)", + body.replace(" ", "") != "{}", + ) + } finally { + socket.close() + runCatching { host.delete("/live-sessions/$sessionId") } + } + } + + private companion object { + const val HTTP_BAD_REQUEST = 400 + const val FOREIGN_ORIGIN = "http://evil.example" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SessionRoundTripE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SessionRoundTripE2eTest.kt new file mode 100644 index 0000000..f872d6f --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SessionRoundTripE2eTest.kt @@ -0,0 +1,256 @@ +package wang.yaojia.webterm.e2e + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.ServerMessage +import wang.yaojia.webterm.wire.Validation +import wang.yaojia.webterm.wire.WireConstants +import java.util.UUID + +/** + * **A34 — instrumented E2E against a real `npm start` host.** + * + * Every test here runs on the device, over a real socket, against this repo's own server. The frames are + * encoded and decoded by production's [wang.yaojia.webterm.wire.MessageCodec], so a drift between the + * frozen client contract and the actual server surfaces here and nowhere else in the suite. + * + * If no host is reachable each test SKIPS with the command needed to make it reachable — see + * [WebTermTestHost]. It never quietly passes. + * + * Every session this class spawns is killed in a `finally`, via the guarded + * `DELETE /live-sessions/:id`, so a run leaves no orphan PTYs behind on the developer's machine. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class SessionRoundTripE2eTest { + + /** + * The core loop the whole product rests on: `attach(null)` → `attached` with a server-issued id → + * typed bytes → the shell's own output comes back. Also pins the two contract details a client gets + * wrong most easily: the id must be adopted from the server (never the one we asked for), and Enter is + * `\r`. + */ + @Test + fun attachThenTypeThenOutput_roundTripsThroughARealPty() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + try { + assertTrue( + "the server-issued session id must be a lowercase v4 UUID: $sessionId", + Validation.isValidSessionId(sessionId), + ) + assertEquals("…and must parse as a UUID", sessionId, UUID.fromString(sessionId).toString()) + + // A marker the shell will echo back through the PTY. `\r` (never `\n`) is what Enter sends. + val marker = "webterm-e2e-${UUID.randomUUID()}" + socket.send(ClientMessage.Input("printf '%s\\n' $marker\r")) + + val output = socket.awaitFrame("the marker echoed back by the shell") { + it is ServerMessage.Output && it.data.contains(marker) + } + assertTrue("expected an output frame, got $output", output is ServerMessage.Output) + } finally { + socket.close() + host.killQuietly(sessionId) + } + } + + /** + * `attach` MUST be the first frame, with the `sessionId` key ALWAYS present (JSON `null` for a new + * session — the server rejects a missing key). Sending something else first must not bind a session: + * the server logs a protocol violation and keeps waiting, so the later `attach` still works. This is + * the ordering guarantee the whole reconnect ladder depends on. + */ + @Test + fun aNonAttachFirstFrameBindsNothing_andTheLaterAttachStillWorks() { + val host = WebTermTestHost.require() + val socket = E2eSocket.dial(host) + var sessionId: String? = null + try { + socket.send(ClientMessage.Input("this must be ignored\r")) + socket.send(ClientMessage.Resize(cols = 100, rows = 40)) + + socket.send(ClientMessage.Attach(sessionId = null)) + val attached = socket.awaitFrame("attached after an out-of-order first frame") { + it is ServerMessage.Attached + } as ServerMessage.Attached + sessionId = attached.sessionId + assertTrue(Validation.isValidSessionId(attached.sessionId)) + } finally { + socket.close() + sessionId?.let { host.killQuietly(it) } + } + } + + /** + * **F5/F6 — reconnect replays the ring buffer, with no dropped bytes on a multi-MB replay.** + * + * The session is made to produce a large volume of output, the client detaches (which must NOT kill + * the PTY), then re-attaches by id. Every byte of the replay has to arrive: the assertion is not "some + * output came back" but that a *numbered sequence* is present, in order, with no gap — the only way to + * catch a truncated or interleaved replay. `MessageCodec` decodes the replay frame, so this also + * exercises the client's ability to receive a single very large text frame. + */ + @Test + fun reconnectReplaysTheRingBufferWithNoDroppedBytes() { + val host = WebTermTestHost.require() + val (first, sessionId) = E2eSocket.attach(host, sessionId = null) + try { + // seq prints REPLAY_LINES numbered lines; awk pads each to ~PAD_BYTES so the ring holds + // hundreds of KB to a few MB of real PTY output rather than a handful of lines. + first.send( + ClientMessage.Input( + "seq 1 $REPLAY_LINES | awk '{ printf \"%s:\", \$1; " + + "for (i = 0; i < $PAD_REPEATS; i++) printf \"$PAD_CHUNK\"; printf \"\\n\" }'\r", + ), + ) + first.awaitFrame("the last line of the generated output") { + it is ServerMessage.Output && it.data.contains("$REPLAY_LINES:") + } + first.drainOutput() + // Detach only. The PTY must survive — that is the product's central design point. + first.close() + assertTrue("the first connection never closed", first.awaitClosed()) + + val stillRunning = host.get("/live-sessions").second + assertTrue( + "the session must survive the detach (PTY lifecycle != WS lifecycle)", + stillRunning.contains(sessionId), + ) + + val (second, adopted) = E2eSocket.attach(host, sessionId = sessionId) + try { + assertEquals("re-attach must adopt the same id", sessionId, adopted) + val replay = second.drainOutput(quietMs = REPLAY_QUIET_MS) + + assertTrue( + "a replay must arrive at all; got ${replay.length} chars", + replay.length > MIN_REPLAY_CHARS, + ) + assertTrue( + "the ESC[0m soft-reset prefix must be passed through, not stripped", + replay.contains(WireConstants.REPLAY_SOFT_RESET_PREFIX), + ) + // The ring is a fixed-size window, so the OLDEST lines are legitimately gone. What must + // never happen is a HOLE: from the first line present to the last, every number must be + // there. That is what a dropped or reordered chunk would break. + // Anchored at a line start: an unanchored `contains("234:")` would also match inside + // "1234:", which would silently inflate the set and make the contiguity check vacuous. + val present = (1..REPLAY_LINES).filter { replay.contains("\n$it:") } + assertTrue("no numbered line survived the replay at all", present.isNotEmpty()) + assertEquals( + "the replay has a hole — lines ${present.first()}..${present.last()} should be contiguous", + (present.first()..present.last()).toList(), + present, + ) + assertTrue( + "the newest line must always be in the ring", + replay.contains("\n$REPLAY_LINES:"), + ) + } finally { + second.close() + } + } finally { + host.killQuietly(sessionId) + } + } + + /** + * A shell that exits ends the session: `exit` is terminal, and the row disappears from + * `/live-sessions`. This is the ordinary exit path — the `-1` spawn-failure path needs a deliberately + * broken server and lives in [SpawnFailureE2eTest]. + */ + @Test + fun aShellThatExitsProducesATerminalExitFrame() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + try { + socket.send(ClientMessage.Input("exit $EXPECTED_EXIT_CODE\r")) + + val exit = socket.awaitFrame("the exit frame") { it is ServerMessage.Exit } as ServerMessage.Exit + assertEquals("the shell's own exit status must be reported verbatim", EXPECTED_EXIT_CODE, exit.code) + } finally { + socket.close() + host.killQuietly(sessionId) + } + } + + /** + * **Kill via the guarded `DELETE /live-sessions/:id`** (the swipe-to-kill action). 204 the first time, + * and **404 the second time counts as success** — "already gone" is the desired end state, which is + * why the client must not treat it as an error (plan §4.3). + */ + @Test + fun killingASessionRemovesItAndASecondKillIsAlreadyGone() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + socket.close() + + val (firstStatus, _) = host.delete("/live-sessions/$sessionId") + assertEquals("the first kill must be a 204", WebTermTestHost.ReachableHost.NO_CONTENT, firstStatus) + + assertTrue( + "the killed session must be gone from /live-sessions", + !host.get("/live-sessions").second.contains(sessionId), + ) + + val (secondStatus, _) = host.delete("/live-sessions/$sessionId") + assertEquals( + "a second kill must be 404 (already gone = success), not an error the UI would surface", + WebTermTestHost.ReachableHost.NOT_FOUND, + secondStatus, + ) + } + + /** + * The Origin 铁律, positive half: the guarded kill route must be REFUSED without a byte-equal `Origin`. + * The negative half (a foreign origin on the WS upgrade, F9) is [CswshDefenceE2eTest]. + */ + @Test + fun theGuardedKillRouteRefusesAForeignOrigin() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + socket.close() + try { + val (status, _) = host.delete("/live-sessions/$sessionId", origin = FOREIGN_ORIGIN) + + assertEquals( + "a foreign Origin must 403 on a state-changing route", + WebTermTestHost.ReachableHost.FORBIDDEN, + status, + ) + assertTrue( + "…and the session must still be alive, i.e. the refusal actually refused", + host.get("/live-sessions").second.contains(sessionId), + ) + } finally { + host.killQuietly(sessionId) + } + } + + /** Best-effort cleanup: 204 and 404 are both fine, and a transport error must not mask a real failure. */ + private fun WebTermTestHost.ReachableHost.killQuietly(sessionId: String) { + runCatching { delete("/live-sessions/$sessionId") } + } + + private companion object { + /** ~REPLAY_LINES × (PAD_REPEATS × 64) bytes of PTY output — comfortably past a single WS frame. */ + const val REPLAY_LINES = 4_000 + const val PAD_REPEATS = 8 + const val PAD_CHUNK = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + const val MIN_REPLAY_CHARS = 100_000 + const val REPLAY_QUIET_MS = 2_500L + + /** An arbitrary non-zero status, chosen so a coincidental 0 cannot pass the assertion. */ + const val EXPECTED_EXIT_CODE = 42 + + /** Not a real host — the point is that it is not in the server's allow-list. */ + const val FOREIGN_ORIGIN = "http://evil.example" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SpawnFailureE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SpawnFailureE2eTest.kt new file mode 100644 index 0000000..cdab7ed --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SpawnFailureE2eTest.kt @@ -0,0 +1,139 @@ +package wang.yaojia.webterm.e2e + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.components.BannerModel +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.ServerMessage +import wang.yaojia.webterm.wire.WireConstants + +/** + * **A34 — a host whose shell cannot start, and the spawn-failure banner.** + * + * ## What was expected, and what a real server actually does — MEASURED, 2026-07-30 + * Plan §4.1 and `src/server.ts` (M4) say a spawn failure surfaces as `exit(-1)` with a required `reason`: + * `manager.handleAttach` throws, the server sends `exit(-1)` and closes that connection. The client turns + * that into the non-retryable "会话启动失败" banner instead of entering the reconnect ladder. + * + * **That path did not fire.** A server started with `SHELL_PATH=/nonexistent/shell` (port 3112, run against + * this suite on 2026-07-30) answered `attached` and then `exit` with **code 1, not -1**. The reason is + * structural, not a configuration slip: node-pty's `pty.fork()` returns successfully and the `execvp` + * failure happens in the FORKED CHILD, which `_exit(1)`s (`node_modules/node-pty/src/unix/pty.cc`, and the + * same in `spawn-helper.cc` for `chdir`). So nothing throws synchronously in `createSession`, `handleAttach` + * returns a live session, and the failure arrives later as an ordinary child exit. On POSIX, `exit(-1)` + * therefore looks **unreachable through any client-visible route** — which is reported to the plan owner + * rather than papered over here. + * + * ## So this class asserts what is true, and never pretends + * - Over the wire, against the deliberately-broken host: a session whose shell cannot be executed produces + * a **terminal, non-zero `exit`** and does not linger in `/live-sessions`. That is the behaviour a client + * must actually handle, and asserting `-1` here would be asserting a fiction. + * - The `-1` → banner reduction is asserted separately and is labelled as a MODEL assertion, not an + * over-the-wire one, because no reachable server input produces `-1` today. If the server ever gains a + * real `-1` path, the wire test above starts distinguishing the two and this stays correct. + * + * Without `-e webtermSpawnFailureHost` the wire test SKIPS with the exact command to provide one — it is + * never a silent pass. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class SpawnFailureE2eTest { + + /** + * A host whose `SHELL_PATH` does not exist must not leave the user with a half-alive session: the + * `exit` frame has to arrive, be non-zero, and the session must be gone afterwards. Anything less and a + * walked-away user would sit on a "connecting…" spinner against a shell that can never run. + */ + @Test + fun aHostWhoseShellCannotStartProducesATerminalNonZeroExit() { + val host = WebTermTestHost.requireSpawnFailureHost() + val socket = E2eSocket.dial(host) + var sessionId: String? = null + try { + socket.send(ClientMessage.Attach(sessionId = null)) + + val frame = socket.awaitFrame("either attached or a terminal exit") { + it is ServerMessage.Attached || it is ServerMessage.Exit + } + (frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId } + + val exit = frame as? ServerMessage.Exit + ?: socket.awaitFrame("the exit that follows a failed exec") { it is ServerMessage.Exit } + as ServerMessage.Exit + + assertTrue( + "a shell that cannot be executed must produce a NON-ZERO exit, got ${exit.code}", + exit.code != CLEAN_EXIT, + ) + sessionId?.let { id -> + assertTrue( + "the dead session must not linger in /live-sessions", + !host.get("/live-sessions").second.contains(id), + ) + } + } finally { + socket.close() + sessionId?.let { runCatching { host.delete("/live-sessions/$it") } } + } + } + + /** + * The CLIENT half of the `exit(-1)` contract, asserted on the model rather than over the wire — see the + * class doc for why no reachable server input produces `-1`. It stays here, next to the wire test, so + * the two are read together and the gap is visible instead of forgotten: if the sentinel ever does + * arrive, this is the behaviour it must drive. + */ + @Test + fun theMinusOneSentinelReducesToTheNonRetryableSpawnFailureBanner() { + val banner = BannerModel.Exited(WireConstants.SPAWN_FAILED_EXIT_CODE, "spawn /nonexistent/shell ENOENT") + + assertTrue("code -1 must be recognised as a spawn failure", banner.isSpawnFailure) + assertTrue("a spawn failure must never show a retry spinner", !banner.showsSpinner) + assertTrue("…and must offer a new session instead", banner.offersNewSession) + assertNotNull("`reason` is what the banner shows the user; it is required on -1", banner.reason) + } + + /** + * The neighbouring case a client must NOT confuse with a spawn failure: a bad `cwd` kills the child + * (node-pty's `spawn-helper` `chdir` → `_exit(1)`), which is an ordinary exit. Reporting it as `-1` + * would stop the client offering a retry in a case where retrying elsewhere works. + */ + @Test + fun aBadCwdIsAnOrdinaryExitNotASpawnFailure() { + val host = WebTermTestHost.require() + val socket = E2eSocket.dial(host) + var sessionId: String? = null + try { + socket.send( + ClientMessage.Attach(sessionId = null, cwd = "/webterm-e2e/definitely-does-not-exist"), + ) + + val frame = socket.awaitFrame("either attached or exit") { + it is ServerMessage.Attached || it is ServerMessage.Exit + } + (frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId } + + val exit = frame as? ServerMessage.Exit + ?: socket.awaitFrame("the child exiting after chdir failed") { it is ServerMessage.Exit } + as ServerMessage.Exit + + assertEquals( + "a bad cwd must not be reported as the spawn-failure sentinel — it is the child that died", + false, + exit.code == WireConstants.SPAWN_FAILED_EXIT_CODE, + ) + } finally { + socket.close() + sessionId?.let { runCatching { host.delete("/live-sessions/$it") } } + } + } + + private companion object { + const val CLEAN_EXIT = 0 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/WebTermTestHost.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/WebTermTestHost.kt new file mode 100644 index 0000000..2dc976d --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/WebTermTestHost.kt @@ -0,0 +1,219 @@ +package wang.yaojia.webterm.e2e + +import androidx.test.platform.app.InstrumentationRegistry +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.junit.Assume +import wang.yaojia.webterm.wire.HostEndpoint +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Locating (or honestly declining to locate) a real WebTerm server for the A34 instrumented E2E suite. + * + * ### The rule this file exists to enforce + * **A test that cannot tell "passed" from "never ran" is worse than no test.** Every E2E test therefore + * goes through [require], which either hands back a *proven-reachable* host or raises a JUnit assumption + * failure carrying the exact command needed to make it reachable. It never returns a host it has not + * spoken to, and it never lets a test body no-op its way to green. + * + * ### Where the address comes from (never hardcoded, never personal) + * 1. `-e webtermHost ` — an instrumentation argument. This is how a LAN address is supplied; it + * is read at run time so no host of anyone's ever enters the source tree. + * 2. Otherwise [EMULATOR_HOST_ALIAS], which is not a personal address at all: `10.0.2.2` is the Android + * emulator's fixed platform alias for the host machine's loopback, documented by Google, and it is the + * right default because the server under test is this repo's own `npm start`. + * + * ### Two things that will bite whoever runs this + * - **`Origin`.** The server derives its allow-list from the host's NIC addresses (`src/config.ts` + * `deriveAllowedOrigins`), and `10.0.2.2` is a *guest-side* alias that never appears among them. So the + * three guarded routes 403 unless the server is started with that origin allowed: + * `ALLOWED_ORIGINS=http://10.0.2.2:3000 npm start`. [require] proves this up front rather than letting + * individual tests fail obscurely. + * - **`WEBTERM_TOKEN`.** If the server is token-gated, every route (and the WS upgrade) needs the + * `webterm_auth` cookie. Pass the token with `-e webtermToken ` and it is exchanged via `POST /auth` + * into the in-memory jar installed on the one [OkHttpClient] both the REST and WS halves use — + * the same "one client, one jar" shape production relies on. The token is never logged and never written + * anywhere. + */ +internal object WebTermTestHost { + + const val ARG_HOST: String = "webtermHost" + const val ARG_TOKEN: String = "webtermToken" + const val ARG_SPAWN_FAILURE_HOST: String = "webtermSpawnFailureHost" + + /** The emulator's fixed alias for the host machine's loopback (an Android platform constant). */ + const val EMULATOR_HOST_ALIAS: String = "http://10.0.2.2:3000" + + private const val CONNECT_TIMEOUT_S = 3L + private const val READ_TIMEOUT_S = 10L + private const val HTTP_OK = 200 + private const val HTTP_UNAUTHORIZED = 401 + private const val HTTP_NO_CONTENT = 204 + private const val HTTP_FORBIDDEN = 403 + private const val HTTP_NOT_FOUND = 404 + + private val JSON = "application/json; charset=utf-8".toMediaType() + + fun argument(name: String): String? = + InstrumentationRegistry.getArguments().getString(name)?.takeIf { it.isNotBlank() } + + /** + * A reachable host, or a JUnit assumption failure (reported as SKIPPED, never as a pass) explaining + * exactly how to provide one. + */ + fun require(): ReachableHost { + val baseUrl = argument(ARG_HOST) ?: EMULATOR_HOST_ALIAS + val endpoint = HostEndpoint.fromBaseUrl(baseUrl) + Assume.assumeTrue( + "`-e $ARG_HOST $baseUrl` is not a usable http(s) base URL, so no E2E host could be built.", + endpoint != null, + ) + return probe(endpoint!!, argument(ARG_TOKEN)) + } + + /** + * The optional second host for the "shell cannot start" case. It needs its own deliberately-broken + * instance because no client input can make a healthy host fail to spawn. + * + * MEASURED 2026-07-30: such a host answers `exit` with code **1**, not the documented `-1` sentinel — + * node-pty's `pty.fork()` succeeds and the `execvp` failure happens in the forked child, which + * `_exit(1)`s. See [SpawnFailureE2eTest] for the full finding. + */ + fun requireSpawnFailureHost(): ReachableHost { + val baseUrl = argument(ARG_SPAWN_FAILURE_HOST) + Assume.assumeTrue( + """ + |SKIPPED — no broken-shell host was supplied, so the failed-spawn path could not be exercised. + |Start a second server whose shell does not exist and point the suite at it: + | SHELL_PATH=/nonexistent/shell PORT=3112 ALLOWED_ORIGINS=http://10.0.2.2:3112 npm start + | ./gradlew :app:connectedDebugAndroidTest \ + | -Pandroid.testInstrumentationRunnerArguments.$ARG_SPAWN_FAILURE_HOST=http://10.0.2.2:3112 + """.trimMargin(), + baseUrl != null, + ) + val endpoint = HostEndpoint.fromBaseUrl(baseUrl!!) + Assume.assumeTrue("`-e $ARG_SPAWN_FAILURE_HOST $baseUrl` is not a usable http(s) base URL.", endpoint != null) + return probe(endpoint!!, argument(ARG_TOKEN)) + } + + private fun probe(endpoint: HostEndpoint, token: String?): ReachableHost { + val cookieJar = MemoryCookieJar() + val client = OkHttpClient.Builder() + .cookieJar(cookieJar) + .connectTimeout(CONNECT_TIMEOUT_S, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_S, TimeUnit.SECONDS) + .pingInterval(0, TimeUnit.MILLISECONDS) + .build() + val host = ReachableHost(endpoint, client) + + val firstStatus = try { + host.get("/live-sessions").first + } catch (e: IOException) { + Assume.assumeNoException( + """ + |SKIPPED — no WebTerm server answered at ${endpoint.baseUrl} (${e.javaClass.simpleName}: ${e.message}). + |Start this repo's own server on the machine hosting the emulator, allowing the guest-side + |origin so the three guarded routes are usable: + | ALLOWED_ORIGINS=${endpoint.originHeader} npm start + |or point the suite at a LAN address instead: + | -Pandroid.testInstrumentationRunnerArguments.$ARG_HOST=http://:3000 + """.trimMargin(), + e, + ) + error("unreachable") // assumeNoException always throws + } + + if (firstStatus == HTTP_UNAUTHORIZED) { + Assume.assumeTrue( + """ + |SKIPPED — ${endpoint.baseUrl} is gated by WEBTERM_TOKEN and no token was supplied. Pass it with + | -Pandroid.testInstrumentationRunnerArguments.$ARG_TOKEN= + |(the token is only exchanged for the webterm_auth cookie; it is never logged or persisted). + """.trimMargin(), + token != null, + ) + val authStatus = host.postJson("/auth", """{"token":${quote(token!!)}}""").first + Assume.assumeTrue( + "SKIPPED — POST /auth at ${endpoint.baseUrl} rejected the supplied token (HTTP $authStatus).", + authStatus == HTTP_NO_CONTENT, + ) + } + + val (status, body) = host.get("/live-sessions") + Assume.assumeTrue( + "SKIPPED — GET /live-sessions at ${endpoint.baseUrl} answered HTTP $status, so this is not a usable WebTerm host.", + status == HTTP_OK, + ) + Assume.assumeTrue( + "SKIPPED — ${endpoint.baseUrl} answered GET /live-sessions with a non-array body, so it is not WebTerm.", + body.trimStart().startsWith("["), + ) + return host + } + + private fun quote(value: String): String = buildString { + append('"') + for (ch in value) { + when (ch) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + else -> append(ch) + } + } + append('"') + } + + /** + * A host that has been *proven* to answer. Exposes only what the E2E tests need, over the one shared + * client (so the auth cookie covers REST and the WS upgrade alike). + */ + internal class ReachableHost(val endpoint: HostEndpoint, val client: OkHttpClient) { + + fun get(path: String): Pair = exchange( + Request.Builder().url(endpoint.baseUrl + path).get().build(), + ) + + /** A GUARDED route: `Origin` stamped byte-equal to [HostEndpoint.originHeader] (plan §4.3). */ + fun postJson(path: String, json: String, origin: String? = null): Pair = exchange( + Request.Builder() + .url(endpoint.baseUrl + path) + .post(json.toByteArray().toRequestBody(JSON)) + .apply { origin?.let { header("Origin", it) } } + .build(), + ) + + fun delete(path: String, origin: String = endpoint.originHeader): Pair = exchange( + Request.Builder().url(endpoint.baseUrl + path).delete().header("Origin", origin).build(), + ) + + private fun exchange(request: Request): Pair = + client.newCall(request).execute().use { it.code to (it.body?.string() ?: "") } + + companion object { + const val OK: Int = HTTP_OK + const val NO_CONTENT: Int = HTTP_NO_CONTENT + const val UNAUTHORIZED: Int = HTTP_UNAUTHORIZED + const val FORBIDDEN: Int = HTTP_FORBIDDEN + const val NOT_FOUND: Int = HTTP_NOT_FOUND + } + } + + /** Minimal in-memory jar — enough to carry `webterm_auth` across REST calls and the WS upgrade. */ + private class MemoryCookieJar : CookieJar { + private val cookies = mutableMapOf() + + override fun loadForRequest(url: HttpUrl): List = synchronized(cookies) { + cookies.values.filter { it.matches(url) } + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) = synchronized(this.cookies) { + cookies.forEach { this.cookies[it.name] = it } + } + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/AlternateScreenScrollRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/AlternateScreenScrollRegressionTest.kt new file mode 100644 index 0000000..c825d04 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/AlternateScreenScrollRegressionTest.kt @@ -0,0 +1,188 @@ +package wang.yaojia.webterm.terminal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ClientMessage + +/** + * REGRESSION — **the CRITICAL one: one finger-swipe inside an alternate-screen TUI killed the process.** + * + * Stock `TerminalView.doScroll` (offsets 56–79 of the pinned `terminal-view-v0.118.0.aar`) turns a scroll + * into `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` **whenever the alternate screen buffer is active**, and + * `handleKeyCode` offsets 15–19 are `mTermSession.getEmulator()` with NO null guard. `mEmulator` is + * guarded; `mTermSession` is not, and it is null forever in this app (plan §6.1). No `TerminalViewClient` + * callback sits anywhere on that path, so installing a client could not help — only taking the gesture + * away from the stock view could. + * + * **Claude Code is an alternate-screen TUI.** That makes this ordinary use, not an edge case: scrolling + * back through a running Claude session was a guaranteed crash. Every test below therefore enters the + * alternate buffer with `ESC [ ? 1 0 4 9 h` FIRST, then drives the real gesture through + * [android.view.View.dispatchTouchEvent] / `dispatchGenericMotionEvent` — the three stock entry points + * (`TerminalView$1.onScroll`, the `TerminalView$1$1` fling runnable, `onGenericMotionEvent`). + * + * "It did not crash" is necessary but not sufficient, so each test also pins the bytes: the alternate- + * buffer branch must emit the DECCKM-correct arrow form, the mouse-tracking branch must emit a mouse + * report and NOT arrows, and the fling must be silent (proof the stock runnable never started). + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class AlternateScreenScrollRegressionTest { + + private lateinit var harness: TerminalTestHarness + + @Before + fun setUp() { + harness = TerminalTestHarness.launch() + harness.awaitAttachSettled() + // ESC [ ? 1 0 4 9 h — enter the alternate screen buffer. This is the state Claude Code, vim, + // htop, less and every git pager run in, and the state that made stock scrolling fatal. + harness.feedAndAwait("\u001b[?1049h", "the alternate screen buffer to be active") { + harness.emulator.isAlternateBufferActive + } + harness.clearSent() + } + + @After + fun tearDown() { + harness.close() + } + + @Test + fun swipeUpInsideTheAlternateBuffer_doesNotCrashAndEmitsDownArrows() { + val claimed = harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX) + + assertTrue( + "the frame must claim the vertical drag — that is what makes stock doScroll unreachable", + claimed, + ) + assertTrue("the alternate buffer must still be active", harness.emulator.isAlternateBufferActive) + val frames = harness.drainSent().filterIsInstance() + assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty()) + assertEquals( + "a finger moving UP the screen scrolls the TUI forward — DPAD_DOWN in CSI form", + emptyList(), + frames.filterNot { it.data == "\u001b[B" }, + ) + } + + @Test + fun swipeDownInsideTheAlternateBuffer_doesNotCrashAndEmitsUpArrows() { + val claimed = harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX) + + assertTrue("the frame must claim the vertical drag", claimed) + val frames = harness.drainSent().filterIsInstance() + assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty()) + assertEquals( + "a finger moving DOWN the screen scrolls back — DPAD_UP in CSI form", + emptyList(), + frames.filterNot { it.data == "\u001b[A" }, + ) + } + + /** + * The arrow FORM has to follow the emulator's live DECCKM bit, because that is what stock + * `handleKeyCode` would have read off `mTermSession.getEmulator()`. A TUI in application-cursor mode + * that received `ESC [ A` instead of `ESC O A` would mis-handle the scroll. + */ + @Test + fun scrollUnderApplicationCursorMode_emitsTheSs3ArrowForm() { + harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") { + harness.emulator.isCursorKeysApplicationMode + } + harness.clearSent() + + harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX) + + val frames = harness.drainSent().filterIsInstance() + assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty()) + assertEquals( + "application-cursor mode must emit the SS3 form", + emptyList(), + frames.filterNot { it.data == "\u001bOA" }, + ) + } + + /** + * `doScroll`'s FIRST branch (offsets 26–53) is mouse tracking, and it must win over the alternate- + * buffer branch. Getting the order wrong would send arrow keys to a program that asked for mouse + * reports — the terminal would appear to ignore the wheel. + */ + @Test + fun wheelUnderMouseTracking_reportsTheWheelAndSendsNoArrowKeys() { + // ESC [ ? 1 0 0 0 h — button-press mouse tracking (what a mouse-aware TUI enables). + harness.feedAndAwait("\u001b[?1000h", "mouse tracking to be active") { + harness.emulator.isMouseTrackingActive + } + harness.clearSent() + + val handled = harness.scrollWheel(up = true) + + assertTrue("the frame must claim the mouse wheel", handled) + val frames = harness.drainSent().filterIsInstance() + assertTrue("the wheel must be reported to the program", frames.isNotEmpty()) + assertEquals( + "mouse tracking must suppress the arrow-key branch entirely", + emptyList(), + frames.filter { it.data == "\u001b[A" || it.data == "\u001bOA" }, + ) + assertTrue( + "a wheel report must be an escape sequence, got ${frames.map { it.data.toCharList() }}", + frames.all { it.data.startsWith("\u001b[") }, + ) + } + + /** Without mouse tracking the wheel takes the same alternate-buffer branch a finger drag does. */ + @Test + fun wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch() { + val handled = harness.scrollWheel(up = true) + + assertTrue("the frame must claim the mouse wheel", handled) + val frames = harness.drainSent().filterIsInstance() + // Stock onGenericMotionEvent scrolls MOUSE_WHEEL_ROWS (3) rows per notch, one key per row. + assertEquals( + "one wheel notch is three rows", + List(WHEEL_ROWS_PER_NOTCH) { ClientMessage.Input("\u001b[A") }, + frames, + ) + } + + /** + * The third stock entry point is the fling runnable `TerminalView$1$1.run`, which keeps calling + * `doScroll` after the finger leaves. It can only start if `mGestureRecognizer` observed the scroll — + * which is exactly what the intercept prevents. So the observable is silence after the drag ends: a + * live fling would keep emitting arrows (and, before the fix, would crash a beat after the gesture, + * which is what made this bug look intermittent). + */ + @Test + fun theStockFlingNeverStarts_soNoBytesArriveAfterTheFingerLeaves() { + harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX, steps = 2) + harness.drainSent() // the drag's own rows + + val afterTheGesture = harness.drainSent(settleMs = FLING_SETTLE_MS) + + assertEquals( + "no fling may continue scrolling after ACTION_UP", + emptyList(), + afterTheGesture, + ) + } + + private fun String.toCharList(): List = map { it.code } + + private companion object { + /** Comfortably past the touch slop and several cell heights on any density. */ + const val DRAG_DISTANCE_PX = 600f + + /** `TerminalScrollGesture.MOUSE_WHEEL_ROWS` — stock scrolls three rows per notch. */ + const val WHEEL_ROWS_PER_NOTCH = 3 + + /** Long enough for a real fling to have produced several frames of scrolling. */ + const val FLING_SETTLE_MS = 800L + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalAutofillRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalAutofillRegressionTest.kt new file mode 100644 index 0000000..7dc6866 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalAutofillRegressionTest.kt @@ -0,0 +1,109 @@ +package wang.yaojia.webterm.terminal + +import android.view.View +import android.view.autofill.AutofillValue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ClientMessage + +/** + * REGRESSION — **a password manager crashed the app just by offering to fill the terminal.** + * + * Stock `TerminalView.autofill(AutofillValue)` (offsets 7–20) writes the filled text straight into + * `mTermSession`, which is null forever here, AND the view advertises itself as fillable + * (`getAutofillType()` = `AUTOFILL_TYPE_TEXT`, `getAutofillValue()` non-null). So on any device with an + * autofill service enabled — the default state for most users — focusing the terminal was enough to arm a + * crash, with no user action beyond accepting the fill. + * + * The fix is not a try/catch around `autofill`: it is keeping the whole subtree OUT of the autofill + * structure, so the framework never builds a fillable node for it. `View.isImportantForAutofill()` walks + * the parent chain and stops at the first `*_EXCLUDE_DESCENDANTS`, and that is precisely the gate + * `AutofillManager` consults before it will notify a service about a view — so it is the gate these tests + * assert, on both the frame and the stock child. + * + * **Deliberately NOT tested here:** the end-to-end loop with a real autofill service (which needs an + * installed, user-selected `AutofillService`) stays on the device-QA checklist. What IS provable on an + * emulator is the framework contract that makes the crash unreachable, plus the belt-and-braces + * assertion that a fill delivered anyway puts nothing on the wire — shell input is exactly the content + * that must never enter an autofill structure (plan §8). + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class TerminalAutofillRegressionTest { + + private lateinit var harness: TerminalTestHarness + + @Before + fun setUp() { + harness = TerminalTestHarness.launch() + harness.awaitAttachSettled() + } + + @After + fun tearDown() { + harness.close() + } + + @Test + fun theTerminalFrameIsExcludedFromTheAutofillStructure() { + val flag = harness.onMain { harness.hostView.importantForAutofill } + val important = harness.onMain { harness.hostView.isImportantForAutofill } + + assertEquals( + "the frame must answer IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS", + View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS, + flag, + ) + assertFalse("the framework must consider the frame unimportant for autofill", important) + } + + /** + * The child is the view that actually carries the crashing `autofill` override, and it is reached + * through the parent-chain walk — which is why the frame overrides the GETTER rather than only + * setting the flag: a later `setImportantForAutofill` cannot quietly re-enable it. + */ + @Test + fun theStockTerminalViewIsExcludedThroughTheParentChain() { + val important = harness.onMain { harness.stockChild.isImportantForAutofill } + + assertFalse( + "the stock TerminalView must be unimportant for autofill via the EXCLUDE_DESCENDANTS walk", + important, + ) + } + + @Test + fun focusingTheTerminalKeepsItExcluded() { + harness.onMain { harness.hostView.requestFocus() } + + assertFalse( + "focus must not make the terminal fillable — focus is what triggered the old crash", + harness.onMain { harness.hostView.isImportantForAutofill }, + ) + assertFalse( + "…nor the stock child", + harness.onMain { harness.stockChild.isImportantForAutofill }, + ) + } + + /** + * Belt and braces: even a fill delivered directly at the frame (bypassing the structure gate) must be + * inert. A password reaching `ClientMessage.Input` would be typed into whatever shell is running. + */ + @Test + fun aFillDeliveredAtTheFrameIsInertAndNeverReachesTheWire() { + harness.onMain { harness.hostView.autofill(AutofillValue.forText("correct-horse-battery-staple")) } + + assertEquals( + "an autofilled value must never be sent to the shell", + emptyList(), + harness.drainSent(), + ) + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalKeyInputRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalKeyInputRegressionTest.kt new file mode 100644 index 0000000..5d5b280 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalKeyInputRegressionTest.kt @@ -0,0 +1,187 @@ +package wang.yaojia.webterm.terminal + +import android.view.KeyEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ClientMessage + +/** + * REGRESSION — **BLOCKER 1: the app died on the first key press.** + * + * `TerminalView.mClient` was never installed, and stock `onKeyDown` dereferences it with no null guard + * (offset 82–92 → `mClient.onKeyDown(keyCode, event, mTermSession)`), so *any* key — hardware or + * soft-keyboard — was an instant NPE on the UI thread. Installing a client that returned `false` would + * only have moved the crash one line down, into `mTermSession.write(getCharacters())` (offset 149–160), + * because `mTermSession` is null forever here (plan §6.1). The soft-keyboard half was different but no + * better: the stock `TerminalView$2` `InputConnection` routes `commitText` into `inputCodePoint`, which + * early-returns when `mTermSession == null` — everything typed vanished silently. + * + * These tests therefore assert **both** halves of the contract on a real device: nothing crashes, AND the + * exact right bytes reach the wire. A test that only proved "no crash" would pass against the silent + * black hole, which was arguably the worse of the two bugs. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class TerminalKeyInputRegressionTest { + + private lateinit var harness: TerminalTestHarness + + @Before + fun setUp() { + harness = TerminalTestHarness.launch() + // Discard the attach-time grid push before any assertion looks at the wire. + harness.awaitAttachSettled() + } + + @After + fun tearDown() { + harness.close() + } + + // ── Hardware keys ──────────────────────────────────────────────────────────────────────────── + + @Test + fun hardwareEnter_sendsCarriageReturnNotLineFeed() { + val consumed = harness.pressKey(KeyEvent.KEYCODE_ENTER) + + assertTrue("the terminal must consume Enter, not leak it to the Activity", consumed) + // Repo-wide gotcha: a terminal Enter is CR (0x0D), never LF. + assertEquals(ClientMessage.Input("\r"), harness.awaitSent("Enter")) + } + + @Test + fun hardwarePrintableKey_sendsThatCharacter() { + harness.pressKey(KeyEvent.KEYCODE_A) + + assertEquals(ClientMessage.Input("a"), harness.awaitSent("the letter a")) + } + + @Test + fun hardwareShiftedKey_sendsTheCapital() { + harness.pressKey(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON) + + assertEquals(ClientMessage.Input("A"), harness.awaitSent("Shift+A")) + } + + @Test + fun hardwareCtrlC_sendsTheInterruptControlByte() { + harness.pressKey(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON) + + assertEquals(ClientMessage.Input("\u0003"), harness.awaitSent("Ctrl+C")) + } + + @Test + fun hardwareBackspace_sendsDel() { + harness.pressKey(KeyEvent.KEYCODE_DEL) + + assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("Backspace")) + } + + /** + * DECCKM (plan §6.3): arrows must go through Termux's `KeyHandler` so application-cursor mode emits + * `ESC O A` rather than `ESC [ A`. Hardcoding the CSI form breaks vim/htop, so the byte form is + * asserted in BOTH modes against the live emulator bit. + */ + @Test + fun arrowUp_followsTheEmulatorsCursorKeyMode() { + harness.pressKey(KeyEvent.KEYCODE_DPAD_UP) + assertEquals( + "normal cursor mode must emit the CSI form", + ClientMessage.Input("\u001b[A"), + harness.awaitSent("Up in normal cursor mode"), + ) + + // ESC [ ? 1 h — DECCKM on (what full-screen TUIs set). + harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") { harness.emulator.isCursorKeysApplicationMode } + harness.clearSent() + + harness.pressKey(KeyEvent.KEYCODE_DPAD_UP) + assertEquals( + "application cursor mode must emit the SS3 form", + ClientMessage.Input("\u001bOA"), + harness.awaitSent("Up in application cursor mode"), + ) + } + + /** + * BACK must stay the Activity's: `WebTermTerminalViewClient.shouldBackButtonBeMappedToEscape()` + * answers false precisely so stock `onKeyDown` cannot route it into the terminal branch (whose next + * stop is `mTermSession.write`). If this ever returns true the terminal screen becomes a trap the + * user cannot leave. + */ + @Test + fun systemBackKey_isNotSwallowedAndSendsNothing() { + val consumedDown = harness.pressKey(KeyEvent.KEYCODE_BACK) + val consumedUp = harness.releaseKey(KeyEvent.KEYCODE_BACK) + + assertFalse("BACK down must fall through to the Activity", consumedDown) + assertFalse("BACK up must fall through to the Activity", consumedUp) + assertEquals("BACK must put no bytes on the wire", emptyList(), harness.drainSent()) + } + + // ── Soft keyboard (the IME seam) ───────────────────────────────────────────────────────────── + + @Test + fun softKeyboardIsOffered_anInputConnectionForTheTerminal() { + val connection = harness.openInputConnection() + + assertTrue( + "the frame must declare itself a text editor or no IME will attach", + harness.onMain { harness.hostView.onCheckIsTextEditor() }, + ) + // Proves the connection is OURS, not the stock TerminalView$2 black hole: committing text has to + // reach the wire. + harness.onMain { connection.commitText("ls -la", 1) } + assertEquals(ClientMessage.Input("ls -la"), harness.awaitSent("a soft-keyboard commit")) + } + + @Test + fun softKeyboardEnter_sendsCarriageReturn() { + val connection = harness.openInputConnection() + + // Some IMEs deliver Enter as a synthesised key event rather than as text. + harness.onMain { + connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) + } + + assertEquals(ClientMessage.Input("\r"), harness.awaitSent("an IME-synthesised Enter")) + } + + @Test + fun softKeyboardBackspace_sendsDel() { + val connection = harness.openInputConnection() + + harness.onMain { connection.deleteSurroundingText(1, 0) } + + assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("an IME backspace")) + } + + /** + * Non-ASCII is passed through VERBATIM (invariant #9 — no filtering, no normalisation). CJK is the + * case that matters because it arrives via composition, and it is also the one a byte-oriented + * implementation is most likely to mangle. + */ + @Test + fun softKeyboardCjkCommit_isPassedThroughVerbatim() { + val connection = harness.openInputConnection() + + harness.onMain { + connection.setComposingText("中文", 1) + connection.commitText("中文", 1) + } + + val frames = harness.drainSent().filterIsInstance() + assertEquals( + "the committed CJK text must reach the wire unmodified", + "中文", + frames.joinToString(separator = "") { it.data }, + ) + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeDrawRaceRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeDrawRaceRegressionTest.kt new file mode 100644 index 0000000..aa70b60 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeDrawRaceRegressionTest.kt @@ -0,0 +1,155 @@ +package wang.yaojia.webterm.terminal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.terminalview.RemoteTerminalSession + +/** + * REGRESSION for a **defect this suite FOUND, and which is NOT yet fixed** (2026-07-30). + * + * ## The defect + * `RemoteTerminalSession.onResize` calls `emulator.resize(cols, rows)` on its confined *writer* thread. + * The stock `TerminalRenderer` reads that same `TerminalBuffer` on the **UI thread** during `onDraw`, and + * it caches `mEmulator.mColumns` / `mRows` at the top of `render()` before walking the rows. So a regrid + * landing mid-frame tears the buffer out from under the renderer, which then indexes rows that have + * already been reallocated to the other size. Both shapes were observed as FATAL exceptions on the main + * thread on the `webterm` AVD (android-35): + * + * ``` + * FATAL EXCEPTION: main + * java.lang.ArrayIndexOutOfBoundsException: length=31; index=31 + * at com.termux.terminal.TerminalRow.getStyle(TerminalRow.java:244) + * at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:104) + * at com.termux.view.TerminalView.onDraw(TerminalView.java:921) + * + * FATAL EXCEPTION: main + * java.lang.IllegalArgumentException: extRow=19, mScreenRows=18, mActiveTranscriptRows=0 + * at com.termux.terminal.TerminalBuffer.externalToInternalRow(TerminalBuffer.java:175) + * at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:83) + * at com.termux.view.TerminalView.onDraw(TerminalView.java:921) + * ``` + * + * ## Why it matters, and how reliably it reproduces — stated precisely + * The window is not exotic: **every** session attach performs an 80x24 -> real-grid reflow (the server + * spawns the PTY at 80x24 and the client corrects it), and every rotation performs another, both while + * frames are being drawn. `TerminalBuffer.resize` reallocates every transcript row, so at production's + * 10 000-row transcript the mutation takes milliseconds rather than microseconds. + * + * **Reproduction is load-dependent, and a green run here does NOT mean the bug is gone.** Measured on the + * `webterm` AVD: + * - Under load (host memory starved, emulator thrashing) it fired constantly: it aborted roughly one + * instrumentation setup in three, hit an alternating-regrid loop within one or two iterations, and + * surfaced BOTH crash shapes above across about six separate runs. + * - On a freshly rebooted, idle emulator, 60 iterations with a concurrent output burst each pass cleanly: + * the reflow simply finishes inside a gap between frames. + * + * That is the signature of a real, unsynchronised data race rather than a deterministic bug — and load is + * exactly the condition a user meets: a mid-range phone rotating while a busy Claude session streams, or a + * cold start replaying a multi-MB ring buffer. So this test is a **stress probe, not a decider**: it fails + * loudly when it catches the tear and must not be read as an all-clear when it does not. The finding stands + * on the captured stack traces above, not on this test's verdict. + * + * It is invisible to the 900-strong JVM suite for the usual reason - no JVM test draws a `View` - and + * invisible to a manual emulator pass that only reaches the pairing screen, because it needs a bound + * session. + * + * ## The fix (verified here before being reported, and NOT applied — `:terminal-view` is not this + * agent's to edit) + * Do the reflow on the renderer's own thread: inside the confined consumer, wrap the mutation as + * `withContext(mainDispatcher) { emulator.resize(cols, rows) }`. That keeps command ORDER intact (the + * consumer still processes one command at a time, so a resize cannot overtake an append) while making the + * mutation mutually exclusive with `onDraw`. It is also what Termux itself does — its own + * `TerminalView.updateSize()` resizes from the UI thread. + * + * **Control experiment, run on this emulator:** 60 alternating regrids driven from the *main* thread with + * `invalidate()` after each one → zero crashes. The identical loop with the regrid on the confined thread + * → FATAL within two iterations. So the hop is sufficient, and the confinement is the cause. + * + * ## Reading this test + * A FAILURE (a main-thread FATAL that also aborts the instrumentation run, which is what the defect does to + * a user's app) is a positive catch: the bug is still there. A PASS means only that this run did not win the + * race. It deliberately uses the production transcript depth + * ([RemoteTerminalSession.TRANSCRIPT_ROWS]) and a concurrent output burst per iteration, which are the two + * knobs that widen the window. Once the reflow is hopped onto the main thread the test becomes a genuine + * decider, because then no interleaving exists that can tear the buffer. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class TerminalResizeDrawRaceRegressionTest { + + private lateinit var harness: TerminalTestHarness + + @Before + fun setUp() { + // Production scrollback depth on purpose: it is what makes the reallocation — and therefore the + // race window — the real size. + harness = TerminalTestHarness.launch(transcriptRows = RemoteTerminalSession.TRANSCRIPT_ROWS) + harness.awaitAttachSettled() + } + + @After + fun tearDown() { + harness.close() + } + + /** + * Alternate between two grids while forcing a redraw after each change — i.e. what a user does by + * rotating the device, folding a foldable, or dragging a multi-window divider. Every iteration must + * survive; a single torn frame is a dead app. + */ + @Test + fun regriddingWhileTheRendererDraws_neverTearsTheBuffer() { + repeat(REGRID_ITERATIONS) { iteration -> + // Keep the confined writer busy so the reflow is queued behind real appends and lands at an + // unpredictable point relative to the frame pipeline. Without this the reflow tends to complete + // in one gap between frames on a fast, idle device and the tear is missed — the race is + // probabilistic, and this is what makes the probability worth testing. + harness.session.feedRemote(OUTPUT_BURST.toByteArray(Charsets.UTF_8)) + val wide = iteration % 2 == 0 + harness.layoutTo( + widthPx = if (wide) WIDE_PX else NARROW_PX, + heightPx = if (wide) SHORT_PX else TALL_PX, + ) + harness.onMain { + harness.hostView.invalidate() + harness.stockChild.invalidate() + } + harness.awaitTrue("iteration $iteration to reflow the emulator") { + harness.emulator.mColumns > 0 && harness.emulator.mRows > 0 + } + } + + // Reaching here at all is the assertion: the failure mode is a process-killing FATAL on the main + // thread, not a false return. This last check adds a coherence probe that indexes the buffer the + // same way the renderer does — a torn buffer throws out of it rather than answering. + val readBack = harness.emulator.screen.getSelectedText( + 0, + 0, + harness.emulator.mColumns - 1, + harness.emulator.mRows - 1, + ) + assertNotNull("the buffer must still be readable at the emulator's advertised dims", readBack) + } + + private companion object { + /** + * The tear is probabilistic: it needs the reflow to land while a frame is mid-flight, which on an + * idle emulator sometimes never happens. 60 iterations with a concurrent output burst each is what + * made it reproduce, rather than the 24 quiet ones that passed on a freshly booted device. + */ + const val REGRID_ITERATIONS = 60 + + /** ~8 KB of real terminal output per iteration: enough to keep the confined writer working. */ + val OUTPUT_BURST: String = (1..64).joinToString("") { "webterm-race-$it " + "-".repeat(64) + "\r\n" } + + const val WIDE_PX = 900 + const val NARROW_PX = 500 + const val SHORT_PX = 700 + const val TALL_PX = 900 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeRegressionTest.kt new file mode 100644 index 0000000..cf360d3 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeRegressionTest.kt @@ -0,0 +1,225 @@ +package wang.yaojia.webterm.terminal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.terminalview.RemoteTerminalSession +import wang.yaojia.webterm.wire.ClientMessage + +/** + * REGRESSION — **`resize` had zero call sites, so the PTY stayed at 80×24 forever.** + * + * `RemoteTerminalSession.updateSize` existed and was unit-tested, and nothing called it. The stock + * fallback cannot help either: `TerminalView.updateSize()` early-returns when `mTermSession == null` + * (offsets 18–25), permanently our case. The visible symptom was every full-screen TUI — Claude Code + * included — rendering into an 80×24 box in the corner of a phone screen, and never reflowing on rotation. + * + * A JVM test cannot catch this class of bug at all: the arithmetic was already correct and tested + * ([wang.yaojia.webterm.terminalview.TerminalGridMath]); what was missing was a real layout pass, real + * cell metrics from a real `TerminalRenderer`, and a real wire. All three exist here. + * + * ### How "the expected grid" is asserted without reading private font metrics + * `TerminalRenderer`'s metrics are only reachable through `:terminal-view`'s `internal` API, and the + * Termux artifact is `implementation`-scoped so its types are off `:app`'s classpath. Rather than + * reflect (which silently rots) the tests assert the **documented formula's own algebra**, which needs no + * metric value: with zero padding, `cols = floor(W / fontWidth)`, so doubling the width must give + * `floor(2W / fontWidth) ∈ {2·cols, 2·cols + 1}` — and nothing else. Same for rows. That pins the grid to + * the plan §6.4 formula rather than merely checking it is "some positive number", and it fails loudly if + * the padding assumption ever stops holding (asserted separately). + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class TerminalResizeRegressionTest { + + private lateinit var harness: TerminalTestHarness + + @Before + fun setUp() { + harness = TerminalTestHarness.launch() + } + + @After + fun tearDown() { + harness.close() + } + + @Test + fun theStockViewHasNoPadding_whichTheGridAlgebraBelowRelies_on() { + val horizontal = harness.onMain { harness.stockChild.paddingLeft } + val vertical = harness.onMain { harness.stockChild.paddingTop } + + assertEquals("the grid algebra in this class assumes zero horizontal padding", 0, horizontal) + assertEquals("the grid algebra in this class assumes zero vertical padding", 0, vertical) + } + + @Test + fun theFirstLayoutPushesARealGridToTheWireAndToTheEmulator() { + val resize = harness.firstResize() + + assertTrue("cols must be a real grid, not the 80x24 default box", resize.cols > 1) + assertTrue("rows must be a real grid, not the 80x24 default box", resize.rows > 1) + assertEquals( + "the local emulator must be reflowed to exactly the dims that went on the wire", + resize.cols to resize.rows, + harness.emulator.mColumns to harness.emulator.mRows, + ) + assertTrue( + "the grid must be within the protocol's validated range", + resize.cols in RESIZE_RANGE && resize.rows in RESIZE_RANGE, + ) + } + + /** + * The §6.4 formula, checked by its own algebra: doubling the pixel box must double the grid to within + * the one cell a `floor` can absorb. A driver that ignored the real metrics (e.g. one that resent the + * default 80×24, or one that divided by a re-measured `Paint`) cannot satisfy this. + */ + @Test + fun doublingTheViewboxDoublesTheGrid_perThePlanFormula() { + val base = harness.firstResize() + + harness.clearSent() + harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX * 2, TerminalTestHarness.DEFAULT_HEIGHT_PX * 2) + val doubled = harness.awaitResize("the grid after doubling the viewbox") + + assertTrue( + "cols=floor(W/fw) ⇒ floor(2W/fw) must be 2·cols or 2·cols+1, was ${doubled.cols} for base ${base.cols}", + doubled.cols == base.cols * 2 || doubled.cols == base.cols * 2 + 1, + ) + assertTrue( + "rows=H/ls ⇒ 2H/ls must be 2·rows or 2·rows+1, was ${doubled.rows} for base ${base.rows}", + doubled.rows == base.rows * 2 || doubled.rows == base.rows * 2 + 1, + ) + assertEquals( + "the emulator must follow the wire", + doubled.cols to doubled.rows, + harness.emulator.mColumns to harness.emulator.mRows, + ) + } + + /** + * A rotation is, to this path, a layout pass with the dimensions swapped — and it must produce a NEW + * grid rather than keep the portrait one (the "does not reflow on rotation" half of the bug). + */ + @Test + fun swappingTheViewboxDimensions_reflowsToATransposedGrid() { + val portrait = harness.firstResize() + + harness.clearSent() + harness.layoutTo(TerminalTestHarness.DEFAULT_HEIGHT_PX, TerminalTestHarness.DEFAULT_WIDTH_PX) + val landscape = harness.awaitResize("the grid after swapping width and height") + + assertTrue("a wider box must give more columns", landscape.cols > portrait.cols) + assertTrue("a shorter box must give fewer rows", landscape.rows < portrait.rows) + } + + /** + * Layout fires far more often than the grid changes (every IME show/hide, inset change and frame of a + * fold drag). Each one becoming a wire frame would mean a SIGWINCH storm, so an unchanged grid must be + * silent — the dedup that makes the forced re-assert below necessary in the first place. + */ + @Test + fun relayoutAtTheSameSize_sendsNothing() { + harness.firstResize() + harness.clearSent() + + harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX, TerminalTestHarness.DEFAULT_HEIGHT_PX) + + assertEquals( + "an unchanged grid must not reach the wire", + emptyList(), + harness.drainSent(), + ) + } + + /** + * **The §6.4 double-dedup bug.** A shared PTY has exactly ONE size, so a device returning to the + * foreground has to re-assert dims it has already sent in order to take the size back from whichever + * device wrote last. Two dedups sit in series on that path — `TerminalResizeDriver.lastGrid` and + * `RemoteTerminalSession.lastSentDims` — and BOTH survive a rebind, so an unflagged re-push dies + * silently and the returning device stays letterboxed forever. + * + * Rebinding the session is production's own trigger (`RemoteTerminalView.session`'s setter and + * `bindEmulator` both call `reassertLastGrid()`), so this test performs exactly that and requires the + * identical grid to reach the wire a second time. + */ + @Test + fun rebindingTheSession_reassertsTheSameGridOntoTheWire() { + val before = harness.firstResize() + harness.clearSent() + + // What a device-switch / pane-show does: re-bind, which must force the memoised grid through both + // dedups even though not one pixel changed. + harness.onMain { harness.view.session = harness.session } + + val reasserted = harness.awaitResize("the forced re-assert after a rebind") + assertEquals( + "the re-assert must carry the SAME grid — it is a claim on the shared PTY size, not a change", + before, + reasserted, + ) + } + + /** + * The same reclaim through the other production trigger: a brand-new session (a real background → + * foreground cycle bumps the generation and builds a fresh emulator at the server's 80×24 default). + * The view already knows the real grid, so it must push it at the new session immediately rather than + * wait for a layout pass that will never come. + */ + @Test + fun attachingAFreshSession_pushesTheMeasuredGridImmediately() { + val measured = harness.firstResize() + + val replacementSent = java.util.concurrent.LinkedBlockingQueue() + val replacement = harness.onMain { + RemoteTerminalSession(engineSend = { message -> replacementSent.put(message) }) + } + try { + harness.onMain { replacement.attachView(harness.view) } + + val pushed = harness.awaitTrueValue("the fresh session to be told the real grid") { + replacementSent.poll()?.let { it as? ClientMessage.Resize } + } + assertEquals( + "a fresh emulator starts at the server's 80x24 default and must be corrected at once", + measured, + pushed, + ) + assertEquals( + "the fresh emulator must be reflowed too", + measured.cols to measured.rows, + replacement.emulator.mColumns to replacement.emulator.mRows, + ) + } finally { + harness.onMain { replacement.close() } + } + } + + // ── helpers ────────────────────────────────────────────────────────────────────────────────── + + /** The attach-time grid push — the first `Resize` the initial layout produced. */ + private fun TerminalTestHarness.firstResize(): ClientMessage.Resize = awaitAttachSettled() + + private fun TerminalTestHarness.awaitResize(what: String): ClientMessage.Resize { + var last: ClientMessage? = null + repeat(MAX_FRAMES_SCANNED) { + val message = awaitSent(what) + last = message + if (message is ClientMessage.Resize) return message + } + throw AssertionError("no Resize frame arrived while waiting for $what; last frame was $last") + } + + private companion object { + /** `WireConstants.RESIZE_RANGE` — the server silently drops anything outside it. */ + val RESIZE_RANGE = 1..1000 + + /** Guards the scan loop from spinning if the wire only ever carries non-Resize frames. */ + const val MAX_FRAMES_SCANNED = 8 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalTestHarness.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalTestHarness.kt new file mode 100644 index 0000000..ec6213f --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalTestHarness.kt @@ -0,0 +1,354 @@ +package wang.yaojia.webterm.terminal + +import android.app.Activity +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import com.termux.terminal.TerminalEmulator +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import wang.yaojia.webterm.terminalview.RemoteTerminalSession +import wang.yaojia.webterm.terminalview.RemoteTerminalView +import wang.yaojia.webterm.wire.ClientMessage +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** + * The device-side rig for the four 2026-07-30 crash regressions. + * + * ### Why these tests can only exist here + * Every defect this rig covers lived in a real `android.view.View` (`TerminalView.onKeyDown`, + * `doScroll`, `autofill`, `updateSize`). The 900-odd JVM tests could not see any of them because no JVM + * test instantiates a View, inflates an `InputConnection`, dispatches a `MotionEvent` or runs a layout + * pass — which is exactly how three crash-on-first-interaction bugs shipped behind a green suite. So the + * rig builds the REAL [RemoteTerminalView] inside a REAL Activity and drives it through the REAL + * framework entry points. Nothing is faked except the wire: [sent] captures the [ClientMessage]s that + * would have gone to the server, which is the only observable a regression can be asserted against + * without a host. + * + * ### Threading contract + * The View and the session's bind path are main-thread-only, so every mutation goes through + * [onMain]. The test body itself runs on the instrumentation thread, which is what lets [awaitSent] and + * [awaitTrue] block: the confined emulator-writer thread and the main looper both stay free to make + * progress while the test waits. Blocking on the main thread instead would deadlock the very handoff + * under test. + */ +internal class TerminalTestHarness private constructor( + private val scenario: ActivityScenario, + val view: RemoteTerminalView, + val session: RemoteTerminalSession, + val root: FrameLayout, + private val sent: LinkedBlockingQueue, +) { + /** The frame that owns focus + the IME contract; the stock Termux view is its only child. */ + val hostView: View get() = view.terminalView + + /** The stock `com.termux.view.TerminalView`, reachable only as a plain [View]: `:terminal-view` + * scopes the Termux artifact as `implementation`, so its type is off `:app`'s classpath. Every + * assertion this rig needs (`isImportantForAutofill`, padding) is plain `View` API anyway. */ + val stockChild: View get() = (hostView as ViewGroup).getChildAt(0) + + val emulator: TerminalEmulator get() = session.emulator + + // ── Driving the view ───────────────────────────────────────────────────────────────────────── + + fun onMain(block: (Activity) -> T): T { + var result: T? = null + var failure: Throwable? = null + val done = CountDownLatch(1) + scenario.onActivity { activity -> + try { + result = block(activity) + } catch (t: Throwable) { + failure = t + } finally { + done.countDown() + } + } + assertTrue("the main thread never ran the block within $MAIN_SYNC_TIMEOUT_MS ms", + done.await(MAIN_SYNC_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + failure?.let { throw it } + @Suppress("UNCHECKED_CAST") + return result as T + } + + /** Resize the hosted view to an exact pixel box and wait for the layout pass to land. */ + fun layoutTo(widthPx: Int, heightPx: Int) { + onMain { + hostView.layoutParams = FrameLayout.LayoutParams(widthPx, heightPx) + hostView.requestLayout() + } + awaitTrue("the view never laid out at ${widthPx}x$heightPx") { + onMain { hostView.width == widthPx && hostView.height == heightPx } + } + } + + /** Dispatch a key through the framework entry point a physical keyboard uses. */ + fun pressKey(keyCode: Int, metaState: Int = 0): Boolean = onMain { + val down = KeyEvent( + /* downTime = */ 0L, + /* eventTime = */ 0L, + KeyEvent.ACTION_DOWN, + keyCode, + /* repeat = */ 0, + metaState, + ) + hostView.dispatchKeyEvent(down) + } + + fun releaseKey(keyCode: Int, metaState: Int = 0): Boolean = onMain { + val up = KeyEvent(0L, 0L, KeyEvent.ACTION_UP, keyCode, 0, metaState) + hostView.dispatchKeyEvent(up) + } + + /** The soft-keyboard seam: the same `InputConnection` an IME would be handed. */ + fun openInputConnection(): InputConnection = onMain { + val editorInfo = EditorInfo() + val connection = hostView.onCreateInputConnection(editorInfo) + assertNotNull("onCreateInputConnection returned null — the IME would have no target", connection) + connection!! + } + + /** + * A vertical finger drag, dispatched exactly as the framework would: one DOWN, [steps] MOVEs and one + * UP, all through [View.dispatchTouchEvent] so `onInterceptTouchEvent` runs for real. + * + * @param totalDeltaPx negative moves the finger UP the screen (content scrolls forward), positive + * moves it DOWN (content scrolls back into history). + * @return whether the frame claimed the gesture — true is what makes the crashing stock + * `doScroll` / fling path unreachable rather than merely unlikely. + */ + fun dragVertically(startY: Float, totalDeltaPx: Float, steps: Int = DRAG_STEPS): Boolean { + val x = 10f + var claimed = false + onMain { + val down = motionEvent(MotionEvent.ACTION_DOWN, x, startY) + hostView.dispatchTouchEvent(down) + down.recycle() + for (step in 1..steps) { + val y = startY + totalDeltaPx * step / steps + val move = motionEvent(MotionEvent.ACTION_MOVE, x, y) + // dispatchTouchEvent returns the ViewGroup's verdict; once the frame intercepts, the + // remainder is routed to its own onTouchEvent, which keeps returning true. + claimed = hostView.dispatchTouchEvent(move) || claimed + move.recycle() + } + val up = motionEvent(MotionEvent.ACTION_UP, x, startY + totalDeltaPx) + hostView.dispatchTouchEvent(up) + up.recycle() + } + return claimed + } + + /** One external-mouse wheel notch — the second stock entry into the crashing `doScroll`. */ + fun scrollWheel(up: Boolean): Boolean = onMain { + val event = wheelEvent(if (up) 1f else -1f) + try { + hostView.dispatchGenericMotionEvent(event) + } finally { + event.recycle() + } + } + + // ── Observing ──────────────────────────────────────────────────────────────────────────────── + + /** The next message that reached the (fake) wire, or fail after [WIRE_TIMEOUT_MS]. */ + fun awaitSent(what: String): ClientMessage { + val message = sent.poll(WIRE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + assertNotNull("nothing reached the wire within $WIRE_TIMEOUT_MS ms while waiting for $what", message) + return message!! + } + + /** Every message that reached the wire within [settleMs] (used to assert "and nothing else"). */ + fun drainSent(settleMs: Long = SETTLE_MS): List { + Thread.sleep(settleMs) + val drained = ArrayList() + sent.drainTo(drained) + return drained + } + + fun clearSent() { + sent.clear() + } + + /** Feed remote bytes and block until the confined writer has applied them. */ + fun feedAndAwait(bytes: String, description: String, predicate: () -> Boolean) { + session.feedRemote(bytes.toByteArray(Charsets.UTF_8)) + awaitTrue(description, predicate) + } + + fun awaitTrue(what: String, predicate: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS) + while (System.nanoTime() < deadline) { + if (predicate()) return + Thread.sleep(POLL_INTERVAL_MS) + } + assertTrue("timed out after $AWAIT_TIMEOUT_MS ms waiting for: $what", predicate()) + } + + fun close() { + onMain { session.close() } + scenario.close() + } + + private fun motionEvent(action: Int, x: Float, y: Float): MotionEvent = + MotionEvent.obtain(DOWN_TIME, DOWN_TIME, action, x, y, 0) + + private fun wheelEvent(vScroll: Float): MotionEvent { + val properties = arrayOf( + MotionEvent.PointerProperties().apply { + id = 0 + toolType = MotionEvent.TOOL_TYPE_MOUSE + }, + ) + val coords = arrayOf( + MotionEvent.PointerCoords().apply { + x = 10f + y = 10f + setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll) + }, + ) + return MotionEvent.obtain( + /* downTime = */ DOWN_TIME, + /* eventTime = */ DOWN_TIME, + MotionEvent.ACTION_SCROLL, + /* pointerCount = */ 1, + properties, + coords, + /* metaState = */ 0, + /* buttonState = */ 0, + /* xPrecision = */ 1f, + /* yPrecision = */ 1f, + /* deviceId = */ 0, + /* edgeFlags = */ 0, + android.view.InputDevice.SOURCE_MOUSE, + /* flags = */ 0, + ) + } + + /** + * Block until the attach-time grid push (80×24 → the real grid) has landed on the wire AND the wire + * has gone quiet, then discard those frames. + * + * Without this, `clearSent()` in a `@Before` races the confined writer: the attach pushes the grid + * asynchronously (twice — `attachView`'s session setter and `bindEmulator` both force a re-assert), + * so a `Resize` can arrive *after* the clear and be mistaken for the first frame a test caused. Two + * of the very first runs of this suite failed exactly that way. + */ + fun awaitAttachSettled(): ClientMessage.Resize { + val grid = awaitTrueValue("the attach-time grid to reach the wire") { + sent.poll() as? ClientMessage.Resize + } + drainSent(settleMs = ATTACH_SETTLE_MS) + return grid + } + + fun awaitTrueValue(what: String, produce: () -> T?): T { + var found: T? = null + awaitTrue(what) { + found = produce() + found != null + } + return found!! + } + + companion object { + /** The whole terminal viewport for the initial layout; individual tests re-layout as needed. */ + const val DEFAULT_WIDTH_PX: Int = 600 + const val DEFAULT_HEIGHT_PX: Int = 800 + + private const val DOWN_TIME = 1_000L + private const val DRAG_STEPS = 8 + private const val MAIN_SYNC_TIMEOUT_MS = 5_000L + private const val WIRE_TIMEOUT_MS = 3_000L + private const val AWAIT_TIMEOUT_MS = 5_000L + private const val POLL_INTERVAL_MS = 20L + private const val SETTLE_MS = 300L + private const val ATTACH_SETTLE_MS = 500L + + /** + * Build the rig: a [ComponentActivity] (supplied by `ui-test-manifest`), a root frame, the real + * [RemoteTerminalView] added to it at [DEFAULT_WIDTH_PX]×[DEFAULT_HEIGHT_PX], and a + * [RemoteTerminalSession] attached to it. + * + * ### Why the view is laid out BEFORE the session attaches + * This is the §6.6 config-change rebind order (a laid-out view, a session bound to it) rather than + * the cold-start order, and it is chosen for a specific reason: it keeps the attach-time 80×24 → + * real-grid reflow *ahead* of the moment the emulator becomes visible to the stock renderer. + * `attachView` queues the forced re-assert (from the session setter) before the Bind command, and + * the confined consumer is FIFO, so the reallocation completes while no frame can be reading the + * buffer. `bindEmulator`'s second re-assert then carries the same dims, which Termux's + * `TerminalEmulator.resize` short-circuits, so it reallocates nothing. + * + * Doing it the other way round — attach first, let the first layout pass fire afterwards — puts + * the reflow on the confined writer thread while the renderer is already drawing, which is the + * still-unfixed defect [TerminalResizeDrawRaceRegressionTest] documents. That defect is real and + * this ordering does not paper over it: it is exercised deliberately by that test and by every + * relayout in [TerminalResizeRegressionTest]. Ordering it away here simply stops a `:terminal-view` + * defect from killing the process during the *setup* of key, scroll and autofill tests that are + * not about resize at all. + */ + fun launch(transcriptRows: Int = RemoteTerminalSession.TRANSCRIPT_ROWS): TerminalTestHarness { + val sent = LinkedBlockingQueue() + val scenario = ActivityScenario.launch(ComponentActivity::class.java) + var view: RemoteTerminalView? = null + var root: FrameLayout? = null + scenario.onActivity { activity -> + val terminal = RemoteTerminalView(activity) + val frame = FrameLayout(activity) + frame.addView( + terminal.terminalView, + FrameLayout.LayoutParams(DEFAULT_WIDTH_PX, DEFAULT_HEIGHT_PX), + ) + activity.setContentView(frame) + view = terminal + root = frame + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + val terminal = view!! + // Wait for the real layout pass. With no session bound yet, TerminalResizeDriver memoises the + // measured grid and pushes nothing (`session?.updateSize` is a no-op) — so nothing has to be + // reflowed while a frame is in flight. + var laidOut = false + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS) + while (!laidOut && System.nanoTime() < deadline) { + val done = CountDownLatch(1) + scenario.onActivity { laidOut = terminal.terminalView.width == DEFAULT_WIDTH_PX } + done.countDown() + if (!laidOut) Thread.sleep(POLL_INTERVAL_MS) + } + assertTrue("the terminal never laid out at $DEFAULT_WIDTH_PX px wide", laidOut) + + var session: RemoteTerminalSession? = null + scenario.onActivity { + val remote = RemoteTerminalSession( + engineSend = { message -> sent.put(message) }, + transcriptRows = transcriptRows, + ) + remote.attachView(terminal) + session = remote + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + val harness = TerminalTestHarness(scenario, terminal, session!!, root!!, sent) + // Focus is a PRECONDITION for the key path, not a convenience: `ViewGroup.dispatchKeyEvent` + // only calls `super.dispatchKeyEvent` (hence `onKeyDown`) when the group itself is FOCUSED + // and laid out, and it has no focusable children here (`FOCUS_BLOCK_DESCENDANTS`). Production + // satisfies this the same way — `dispatchTouchEvent` claims focus on ACTION_DOWN and + // `showSoftKeyboard()` calls `requestFocus()` — so an unfocused terminal receiving no hardware + // keys is correct framework behaviour, not a defect. Asserting it here keeps a silent focus + // regression from turning the key tests into vacuous passes. + harness.awaitTrue("the terminal frame to take focus") { + harness.onMain { harness.hostView.requestFocus() || harness.hostView.isFocused } + } + return harness + } + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/GateSurfacesUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/GateSurfacesUiTest.kt new file mode 100644 index 0000000..7983577 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/GateSurfacesUiTest.kt @@ -0,0 +1,159 @@ +package wang.yaojia.webterm.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.components.GateBanner +import wang.yaojia.webterm.components.PlanGateSheet +import wang.yaojia.webterm.designsystem.WebTermTheme +import wang.yaojia.webterm.session.GateState +import wang.yaojia.webterm.viewmodels.GateDecision +import wang.yaojia.webterm.wire.GateKind + +/** + * **Plan §7 Compose UI tests — the gate surfaces.** + * + * These are the two surfaces a remote approval is made from, so the load-bearing contract is not the layout + * (device-QA) but that **each affordance reports the decision it is labelled with, carrying the epoch of the + * gate that was on screen**. The epoch is the stale-guard seam: `GateViewModel.decide` drops a tap whose + * epoch is no longer live, which is what stops a tap landing on a gate the user never saw. A test that only + * checked the copy would let a swapped-callback bug through — approving when the user pressed reject is the + * single worst failure this app can have. + * + * The server-supplied `detail` is also asserted to render, because it must render as INERT text (plan §8) — + * untrusted tool/OSC content with no markdown and no autolink. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class GateSurfacesUiTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun toolGateBanner_reportsApproveWithTheOnScreenEpoch() { + val decisions = mutableListOf>() + compose.setContent { + WebTermTheme { + GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e }) + } + } + + compose.onNodeWithText(DETAIL).assertIsDisplayed() + compose.onNodeWithText(APPROVE).performClick() + + assertEquals(listOf(GateDecision.APPROVE to TOOL_EPOCH), decisions) + } + + @Test + fun toolGateBanner_reportsRejectWithTheOnScreenEpoch() { + val decisions = mutableListOf>() + compose.setContent { + WebTermTheme { + GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e }) + } + } + + compose.onNodeWithText(REJECT).performClick() + + assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions) + } + + /** + * A previewless gate is a NORMAL state, not an error: the server only sends a preview for a held + * Bash/Edit-family tool, and a malformed one decodes to null rather than costing us the gate. So the + * banner must still be fully usable with no preview. + */ + @Test + fun toolGateBanner_withNoPreviewIsStillFullyDecidable() { + val decisions = mutableListOf>() + compose.setContent { + WebTermTheme { + GateBanner(gate = toolGate.copy(preview = null), onDecide = { d, e -> decisions += d to e }) + } + } + + compose.onNodeWithText(APPROVE).assertIsDisplayed() + compose.onNodeWithText(REJECT).performClick() + + assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions) + } + + /** + * The plan gate is THREE-way, and the third option is the one most easily got wrong: 「继续计划」 keeps + * planning, i.e. it wires to `reject`, not to a second kind of approval. + */ + @Test + fun planGateSheet_offersAllThreeDecisionsWithTheOnScreenEpoch() { + val decisions = mutableListOf>() + compose.setContent { + WebTermTheme { + PlanGateSheet(gate = planGate, onDecide = { d, e -> decisions += d to e }, onDismiss = {}) + } + } + + compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed() + compose.onNodeWithText(PLAN_DETAIL).assertIsDisplayed() + + compose.onNodeWithText(ACCEPT_EDITS).performClick() + compose.onNodeWithText(KEEP_PLANNING).performClick() + compose.onNodeWithText(APPROVE, useUnmergedTree = false).performClick() + + assertEquals( + listOf( + GateDecision.ACCEPT_EDITS to PLAN_EPOCH, + GateDecision.REJECT to PLAN_EPOCH, + GateDecision.APPROVE to PLAN_EPOCH, + ), + decisions, + ) + } + + /** + * Dismissing the sheet (scrim tap / back) must resolve NOTHING — the gate stays held until an explicit + * decision. Silently treating a dismissal as a reject would cancel a user's work behind their back. + */ + @Test + fun planGateSheet_dismissalDecidesNothing() { + val decisions = mutableListOf>() + var dismissals = 0 + compose.setContent { + WebTermTheme { + PlanGateSheet( + gate = planGate, + onDecide = { d, e -> decisions += d to e }, + onDismiss = { dismissals += 1 }, + ) + } + } + + // The sheet is on screen and no decision has been reported; the dismiss callback is the only other + // exit, and it is wired to a handler that resolves nothing. + compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed() + assertEquals(emptyList>(), decisions) + assertEquals(0, dismissals) + } + + private companion object { + const val TOOL_EPOCH = 7 + const val PLAN_EPOCH = 11 + const val DETAIL = "Bash" + const val PLAN_DETAIL = "Refactor the auth module into 3 files." + + const val APPROVE = "批准" + const val REJECT = "拒绝" + const val ACCEPT_EDITS = "批准并自动接受" + const val KEEP_PLANNING = "继续计划" + const val PLAN_TITLE = "批准执行计划" + + val toolGate = GateState(kind = GateKind.TOOL, detail = DETAIL, epoch = TOOL_EPOCH) + val planGate = GateState(kind = GateKind.PLAN, detail = PLAN_DETAIL, epoch = PLAN_EPOCH) + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/KeyBarUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/KeyBarUiTest.kt new file mode 100644 index 0000000..a57a110 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/KeyBarUiTest.kt @@ -0,0 +1,150 @@ +package wang.yaojia.webterm.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.components.KeyBar +import wang.yaojia.webterm.designsystem.WebTermTheme + +/** + * **Plan §7 Compose UI test — the mobile key-bar, pinned above the IME.** + * + * The key-bar is how a phone produces the keys Claude Code needs and a soft keyboard cannot: Esc, ⇧Tab, + * arrows, Ctrl-chords. Its defining property is that a tap goes **straight to the wire**, bypassing the IME + * entirely (mirroring the web client's `ws.send` bypass) — that is what stops the soft keyboard popping up + * every time the user dismisses a Claude prompt. So the assertions are on the bytes each button emits, not + * on its looks. + * + * What stays device-QA: that the bar is *visually* above the IME. `WindowInsets.ime` reports zero in an + * instrumented test with no keyboard raised, so an assertion about its on-screen position would be + * measuring the absence of a keyboard, not the inset behaviour. The union-with-navigation-bars fallback that + * makes the bar sit above the nav bar when the keyboard is hidden IS exercised here (the bar renders and is + * hittable), and the real above-the-IME check is on the checklist. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class KeyBarUiTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun escTap_sendsTheEscapeByteDirectly() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("Esc").performClick() + + assertEquals(listOf("\u001b"), sent) + } + + @Test + fun enterTap_sendsCarriageReturnNotLineFeed() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("⏎").performClick() + + // The repo-wide gotcha, on the surface a phone user hits most often. + assertEquals(listOf("\r"), sent) + } + + @Test + fun ctrlCTap_sendsTheInterruptControlByte() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("^C").performClick() + + assertEquals(listOf("\u0003"), sent) + } + + @Test + fun shiftTabTap_sendsTheModeCycleSequence() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("⇧Tab").performClick() + + assertEquals(listOf("\u001b[Z"), sent) + } + + /** + * The bar is horizontally scrollable precisely so every key stays reachable on a narrow phone; the keys + * past the fold are the ones a layout regression would silently drop. Reaching one by its + * `contentDescription` (which is also its accessibility label) proves it is present and hittable. + */ + @Test + fun everyKeyIsReachable_includingTheOnesPastTheFold() { + val sent = mutableListOf() + setUpKeyBar(sent) + + // Off-screen keys must be reached the way a user reaches them — by scrolling the bar. A bare + // performClick() on a node outside the viewport fails, which is itself the proof that this key + // really does sit past the fold rather than being trivially present. + compose.onNodeWithContentDescription(SLASH_DESCRIPTION).performScrollTo().performClick() + + assertEquals(listOf("/"), sent) + } + + @Test + fun tapsAccumulateInOrder_soTwoFastTapsCannotSwap() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("↑").performClick() + compose.onNodeWithText("↓").performClick() + compose.onNodeWithText("⏎").performClick() + + assertEquals(listOf("\u001b[A", "\u001b[B", "\r"), sent) + } + + /** + * A key-bar tap must never route through a text field, or the soft keyboard would open. There is no + * public hook to observe "the IME was not asked to open", but there IS an observable proxy: the bar + * contains no text-input node at all, so nothing on it can request the IME. Combined with the byte + * assertions above (which prove the tap reached the wire), that pins the bypass. + */ + @Test + fun theBarHoldsNoTextInput_soATapCannotRaiseTheKeyboard() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("Esc").assertIsDisplayed() + val editableNodes = compose + .onAllNodes(hasSetTextAction()) + .fetchSemanticsNodes(atLeastOneRootRequired = false) + + assertTrue("the key-bar must contain no editable text node", editableNodes.isEmpty()) + } + + private fun setUpKeyBar(sink: MutableList) { + compose.setContent { + WebTermTheme { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { + KeyBar(onSend = { bytes -> sink += bytes }) + } + } + } + } + + private companion object { + /** `KEYBAR_LAYOUT`'s accessibility label for the command-launcher key, which sits past the fold. */ + const val SLASH_DESCRIPTION = "Slash — command launcher" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/ReconnectBannerUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/ReconnectBannerUiTest.kt new file mode 100644 index 0000000..1394d31 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/ReconnectBannerUiTest.kt @@ -0,0 +1,134 @@ +package wang.yaojia.webterm.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.components.BannerModel +import wang.yaojia.webterm.session.ConnectionState +import wang.yaojia.webterm.session.Connection +import wang.yaojia.webterm.session.FailureReason +import wang.yaojia.webterm.components.ReconnectBanner +import wang.yaojia.webterm.designsystem.WebTermTheme +import kotlin.time.Duration.Companion.seconds + +/** + * **Plan §7 Compose UI test — the reconnect banner, and specifically its precedence rule.** + * + * The banner is the only place the app tells a walked-away user *why* their session went quiet, so the + * failure modes it must not have are: showing a retry spinner for something that can never succeed, and + * hiding a terminal state behind a transient one. The reduction itself is JVM-tested; what only a device can + * check is that the rendered surface honours it — the spinner really is absent, the affordance really is + * present and really fires, and the untrusted exit `reason` really renders inert. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class ReconnectBannerUiTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun hiddenRendersNothingAtAll() { + setUpBanner(BannerModel.Hidden) + + val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot()) + .fetchSemanticsNodes(atLeastOneRootRequired = false) + // A root always exists; what must not exist is any banner content under it. + assertTrue("Hidden must render no banner content", nodes.all { it.children.isEmpty() }) + } + + @Test + fun reconnectingShowsTheAttemptCountdownAndASpinner() { + setUpBanner(BannerModel.Reconnecting(attempt = 3, countdown = 4.seconds)) + + compose.onNodeWithText("连接断开,重连中(第 3 次,4 秒后重试)").assertIsDisplayed() + assertTrue("a retrying banner must show a spinner", hasSpinner()) + } + + /** + * The replay-too-large wall is NON-retryable: reconnecting would hit the same wall forever, so a + * spinner here would be a lie that never resolves. The copy has to be actionable and the only way out + * is a new session. + */ + @Test + fun aNonRetryableFailureShowsNoSpinnerAndOffersANewSession() { + var newSessions = 0 + setUpBanner(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE)) { newSessions += 1 } + + assertTrue("a non-retryable wall must NOT show a retry spinner", !hasSpinner()) + compose.onNodeWithText("新会话").performClick() + + assertEquals(1, newSessions) + } + + /** + * `exit(-1)` is a spawn failure, and its `reason` is server-supplied, i.e. untrusted — it must render as + * inert text inside the banner copy, never as a link or markup (plan §8). + */ + @Test + fun aSpawnFailureRendersTheServerReasonInertly() { + setUpBanner(BannerModel.Exited(code = -1, reason = UNTRUSTED_REASON)) + + compose.onNodeWithText("会话启动失败:$UNTRUSTED_REASON").assertIsDisplayed() + assertTrue("a terminal exit must not show a spinner", !hasSpinner()) + compose.onNodeWithText("开新会话").assertIsDisplayed() + } + + @Test + fun anOrdinaryExitShowsTheCodeAndTheNewSessionAffordance() { + var newSessions = 0 + setUpBanner(BannerModel.Exited(code = 130, reason = null)) { newSessions += 1 } + + compose.onNodeWithText("会话已结束(退出码 130)").assertIsDisplayed() + compose.onNodeWithText("开新会话").performClick() + + assertEquals(1, newSessions) + } + + /** + * **The precedence rule, rendered.** A `connecting` event arriving after the session already exited must + * NOT replace the terminal banner with a spinner — otherwise a walked-away user sees "connecting…" + * forever and never learns the session is over. The reduction is asserted and then the reduced model is + * rendered, so copy and rule are checked as one thing. + */ + @Test + fun aTerminalBannerIsStickyOverATransientOne() { + val exited = BannerModel.Exited(code = 1, reason = null) + val afterConnecting = wang.yaojia.webterm.components.bannerModel( + current = exited, + event = Connection(ConnectionState.Connecting), + ) + + assertEquals("a transient event must not override a terminal banner", exited, afterConnecting) + + setUpBanner(afterConnecting) + compose.onNodeWithText("会话已结束(退出码 1)").assertIsDisplayed() + assertTrue(!hasSpinner()) + } + + private fun hasSpinner(): Boolean = compose + .onAllNodes(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) + .fetchSemanticsNodes(atLeastOneRootRequired = false) + .isNotEmpty() + + private fun setUpBanner(model: BannerModel, onNewSession: () -> Unit = {}) { + compose.setContent { + WebTermTheme { ReconnectBanner(model = model, onNewSession = onNewSession) } + } + } + + private companion object { + /** Server-supplied and therefore untrusted; it must appear verbatim and inert. */ + const val UNTRUSTED_REASON = "spawn /bin/nope ENOENT" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/SessionListUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/SessionListUiTest.kt new file mode 100644 index 0000000..958c764 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/SessionListUiTest.kt @@ -0,0 +1,134 @@ +package wang.yaojia.webterm.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.api.models.LiveSessionInfo +import wang.yaojia.webterm.components.ContinueLastBanner +import wang.yaojia.webterm.components.ContinueLastModel +import wang.yaojia.webterm.components.SessionListRow +import wang.yaojia.webterm.components.continueLastModel +import wang.yaojia.webterm.designsystem.DisplayStatus +import wang.yaojia.webterm.designsystem.WebTermTheme +import wang.yaojia.webterm.viewmodels.SessionRow +import wang.yaojia.webterm.wire.ClaudeStatus +import java.util.UUID + +/** + * **Plan §7 Compose UI tests — session-list rows and the continue-last banner.** + * + * Two things only a device can check here. First, that a row renders host-supplied strings **inert**: the + * title arrives from an OSC escape the host (or something running on it) chose, so it must be plain + * [androidx.compose.material3.Text] with no linkify and no markdown (plan §8). Second, that "shown iff there + * is something to show" holds — a `null` model must render literally nothing, because a dead + * continue-affordance that navigates nowhere is worse than no affordance. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class SessionListUiTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun aRowRendersTheSanitizedTitleAndTheGeometry() { + compose.setContent { + WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = {}) } + } + + compose.onNodeWithText(TITLE).assertIsDisplayed() + // The geometry label uses U+00D7 MULTIPLICATION SIGN, not the letter x. + compose.onNodeWithText("161×50", substring = true).assertIsDisplayed() + } + + @Test + fun tappingARowOpensThatSession() { + var opened = 0 + compose.setContent { + WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = { opened += 1 }) } + } + + compose.onNodeWithText(TITLE).performClick() + + assertEquals(1, opened) + } + + /** + * A hostile OSC title must appear as characters, not as behaviour. `TitleSanitizer` strips bidi and + * zero-width runs upstream (JVM-tested); what this pins is that whatever survives is rendered verbatim + * and is not turned into a link by autolinking. + */ + @Test + fun anUntrustedLookingTitleIsRenderedAsPlainCharacters() { + compose.setContent { + WebTermTheme { SessionListRow(row = row(title = URL_LOOKING_TITLE), onOpen = {}) } + } + + compose.onNodeWithText(URL_LOOKING_TITLE).assertIsDisplayed() + } + + @Test + fun continueLastBannerIsAbsentWhenThereIsNoLastSession() { + assertEquals("no last session must reduce to no model", null, continueLastModel(null)) + + compose.setContent { + WebTermTheme { ContinueLastBanner(model = continueLastModel(null), onContinue = {}) } + } + + val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot()) + .fetchSemanticsNodes(atLeastOneRootRequired = false) + assertTrue("a null model must render nothing at all", nodes.all { it.children.isEmpty() }) + } + + @Test + fun continueLastBannerReOpensTheRememberedSession() { + val remembered = mutableListOf() + compose.setContent { + WebTermTheme { + ContinueLastBanner( + model = ContinueLastModel(LAST_SESSION_ID), + onContinue = { remembered += it }, + ) + } + } + + compose.onNodeWithText("继续上次会话").performClick() + + assertEquals( + "the banner must hand back the id it was rendered with, not a re-derived one", + listOf(LAST_SESSION_ID), + remembered, + ) + } + + private fun row(title: String) = SessionRow( + info = LiveSessionInfo( + id = UUID.fromString(LAST_SESSION_ID), + createdAt = 1_700_000_000_000L, + clientCount = 1, + status = ClaudeStatus.WORKING, + exited = false, + cwd = "/Users/dev/project", + title = title, + cols = 161, + rows = 50, + ), + displayStatus = DisplayStatus.Working, + title = title, + isUnread = true, + ) + + private companion object { + const val TITLE = "claude" + const val URL_LOOKING_TITLE = "https://example.com/not-a-link" + const val LAST_SESSION_ID = "a1b2c3d4-5678-4abc-9def-000000000000" + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 350ade0..7e3733d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,13 +12,32 @@ + + + android:usesCleartextTraffic="true"> Unit, modifier: Modifier = Modifier, + preview: ApprovalPreview? = null, ) { WebTermCard(modifier = modifier.fillMaxWidth()) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) { @@ -63,6 +69,9 @@ public fun GateBanner( ) } } + // WHAT is being approved sits between the label and the buttons, so the decision and the + // evidence are never on different screens. Absent → the name-only bar, unchanged. + ApprovalPreviewBlock(preview) Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) { Text(text = "拒绝") @@ -75,6 +84,124 @@ public fun GateBanner( } } +// ── W1: the approval preview (shared by GateBanner and PlanGateSheet) ─────────── + +/** How many diff/command lines a gate surface paints before it says "truncated". The server already + * caps at 40 lines / 4 KB; this is the on-phone reading limit, so the buttons stay reachable. */ +private const val PREVIEW_MAX_VISIBLE_LINES = 20 + +/** + * W1 — render WHAT a held approval would do: the shell command, or the one-file diff. + * + * Everything here is **untrusted** (server-derived from the hook's `tool_input`, which Claude and the + * repo contents influence) and therefore INERT: plain [Text] only — no Markdown, no autolink, no + * `AnnotatedString` link detection (plan §8). A `null` preview renders NOTHING: absent is a normal + * state (plan gates and unknown tools have nothing reviewable), never an error. + */ +@Composable +internal fun ApprovalPreviewBlock(preview: ApprovalPreview?, modifier: Modifier = Modifier) { + if (preview == null) return + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Spacing.xs2), + ) { + when (preview) { + is ApprovalPreview.Command -> CommandPreview(preview) + is ApprovalPreview.Diff -> DiffPreview(preview.file) + } + if (preview.truncated) { + Text( + text = GatePreviewCopy.TRUNCATED, + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun CommandPreview(preview: ApprovalPreview.Command) { + // Newlines are content in a shell command, so the lines are kept and rendered as lines. The + // sanitizer drops every other control byte (defence in depth over the server's own stripping). + val lines = (preview.inertText() ?: return).lines() + for (line in lines.take(PREVIEW_MAX_VISIBLE_LINES)) { + Text( + text = line, + style = WebTermType.monoTabular(12), + color = MaterialTheme.colorScheme.onSurface, + ) + } + if (lines.size > PREVIEW_MAX_VISIBLE_LINES) ClippedNote() +} + +@Composable +private fun DiffPreview(file: PreviewDiffFile) { + Text( + text = GatePreviewCopy.diffHeader(file), + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (file.binary) { + Text(text = GatePreviewCopy.BINARY, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + var painted = 0 + for (hunk in file.hunks) { + if (painted >= PREVIEW_MAX_VISIBLE_LINES) break + DiffLineText(hunk.header, DIFF_KIND_HUNK) + painted++ + for (line in hunk.lines) { + if (painted >= PREVIEW_MAX_VISIBLE_LINES) break + DiffLineText(line.text, line.kind) + painted++ + } + } + val total = file.hunks.sumOf { it.lines.size + 1 } + if (total > painted) ClippedNote() +} + +@Composable +private fun DiffLineText(text: String, kind: String) { + // An UNKNOWN kind still renders (just uncoloured): dropping a line the client cannot classify + // would hide part of what the user is approving. + val color = when (kind) { + DIFF_KIND_ADDED -> WebTermColors.statusWorking + DIFF_KIND_REMOVED -> WebTermColors.statusStuck + DIFF_KIND_HUNK, DIFF_KIND_META -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurface + } + Text(text = text, style = WebTermType.monoTabular(12), color = color, maxLines = 1, overflow = TextOverflow.Ellipsis) +} + +@Composable +private fun ClippedNote() { + Text( + text = GatePreviewCopy.CLIPPED, + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** Wire `DiffLineKind` values (`src/types.ts`) — raw strings, so an unknown kind stays itself. */ +private const val DIFF_KIND_ADDED = "added" +private const val DIFF_KIND_REMOVED = "removed" +private const val DIFF_KIND_HUNK = "hunk" +private const val DIFF_KIND_META = "meta" + +/** Preview copy (local UI text). */ +internal object GatePreviewCopy { + const val TRUNCATED: String = "… 服务端已截断" + const val CLIPPED: String = "… 仅显示前 $PREVIEW_MAX_VISIBLE_LINES 行" + const val BINARY: String = "二进制文件" + + fun diffHeader(file: PreviewDiffFile): String { + val path = if (file.oldPath != file.newPath) "${file.oldPath} → ${file.newPath}" else file.newPath + return "$path +${file.added}/-${file.removed}" + } +} + // ── Preview ───────────────────────────────────────────────────────────────────── @Preview(name = "GateBanner") @@ -84,6 +211,7 @@ private fun GateBannerPreview() { GateBanner( gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3), onDecide = { _, _ -> }, + preview = ApprovalPreview.Command("rm -rf build/\nnpm run build", truncated = true), ) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt b/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt index 1350cbb..396620f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt @@ -20,6 +20,7 @@ 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.ApprovalPreview import wang.yaojia.webterm.wire.GateKind /** @@ -49,6 +50,7 @@ public fun PlanGateSheet( onDecide: (GateDecision, Int) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, + preview: ApprovalPreview? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { @@ -69,6 +71,9 @@ public fun PlanGateSheet( overflow = TextOverflow.Ellipsis, ) } + // W1: a plan gate usually has nothing reviewable, but when the server DOES send a preview + // (a plan that resolves straight into an Edit/Bash) it belongs above the buttons. + ApprovalPreviewBlock(preview) Button( onClick = { onDecide(GateDecision.APPROVE, gate.epoch) }, modifier = Modifier.fillMaxWidth(), diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt b/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt index 0f19099..3a397e3 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.DpOffset import wang.yaojia.webterm.nav.LayoutMode import wang.yaojia.webterm.nav.PointerMenuPolicy +import wang.yaojia.webterm.viewmodels.SessionRow +import java.util.UUID /** * # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu. @@ -59,7 +61,12 @@ public fun PointerContextMenu( val density = LocalDensity.current Box( - modifier = modifier.pointerInput(actions) { + // Keyed on Unit, NOT on [actions]: the gesture body only records the anchor and opens the menu — + // it never reads [actions] (the DropdownMenu below reads the current list on every composition). + // Keying on the list would restart the pointer handler on every recomposition, because the + // per-entry lambdas in a freshly-built List are never equal to the previous + // ones — a right-click landing during that restart window would be dropped. + modifier = modifier.pointerInput(Unit) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() @@ -95,3 +102,41 @@ public fun PointerContextMenu( /** 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) + +// ── The session-row action set (A26 — the one surface the pointer menu is attached to) ─────────────── + +/** 打开会话 — the same target a row tap has. */ +public const val MENU_LABEL_OPEN: String = "打开会话" + +/** 在当前目录开新会话 — `attach(null, cwd)` in the row's own cwd (plan §1 new-session-in-cwd). */ +public const val MENU_LABEL_NEW_IN_CWD: String = "在当前目录开新会话" + +/** 终止会话 — `DELETE /live-sessions/:id`, the pointer equivalent of swipe-to-kill. */ +public const val MENU_LABEL_KILL: String = "终止会话" + +/** + * The [PointerContextMenu] entries for one session row — the pure half of the A26 pointer menu (the + * gesture and the placement are device-QA; [PointerMenuPolicy] gates whether the menu exists at all). + * + * Mirrors iOS's pointer menu with ONE deliberate omission: **copy is absent**. Copy-out lives in the + * terminal's own text-selection `ActionMode`, not in a list-row menu, and the row has no selected text to + * copy — advertising it would be a dead entry. + * + * The new-session entry appears only when BOTH a usable cwd is known AND a spawn handler is wired: the + * server may omit `cwd` (or send blank), and `attach(null, cwd)` with no directory is not the same action. + * Every label here is a fixed app string — the server-provided cwd is carried in the CALLBACK only and is + * never rendered into a menu entry (plan §8: untrusted server strings stay out of chrome). + */ +public fun sessionRowMenuActions( + row: SessionRow, + onOpen: (UUID) -> Unit, + onNewSessionInCwd: ((String) -> Unit)?, + onKill: (UUID) -> Unit, +): List = buildList { + add(ContextMenuAction(MENU_LABEL_OPEN) { onOpen(row.id) }) + val cwd = row.info.cwd?.takeIf { it.isNotBlank() } + if (cwd != null && onNewSessionInCwd != null) { + add(ContextMenuAction(MENU_LABEL_NEW_IN_CWD) { onNewSessionInCwd(cwd) }) + } + add(ContextMenuAction(MENU_LABEL_KILL) { onKill(row.id) }) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/QueuePanel.kt b/android/app/src/main/java/wang/yaojia/webterm/components/QueuePanel.kt new file mode 100644 index 0000000..1970b11 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/QueuePanel.kt @@ -0,0 +1,271 @@ +package wang.yaojia.webterm.components + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.Alignment +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 +import wang.yaojia.webterm.designsystem.WebTermType +import wang.yaojia.webterm.viewmodels.QueueNotice +import wang.yaojia.webterm.viewmodels.QueueUiState +import wang.yaojia.webterm.viewmodels.QueueViewModel + +/** + * # QueueBadge (W2) — the "N queued" pill. + * + * Renders NOTHING for a null (unknown / pre-W2 server) or non-positive depth, so a host without the + * queue feature shows no affordance at all and an empty queue does not shout "0". + * + * The number is always the server's own count — the live `queue` frame or `GET /live-sessions`'s + * `queueLength` — never a locally incremented guess. + */ +@Composable +public fun QueueBadge(depth: Int?, modifier: Modifier = Modifier) { + val pending = depth ?: return + if (pending <= 0) return + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(Radius.sm8)) + .padding(horizontal = Spacing.sm8, vertical = Spacing.xs2), + ) { + Text( + text = "$pending 待发", + style = WebTermType.metaMono, // tabular numerals so the pill does not jitter as N changes + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + ) + } +} + +/** + * # QueuePanel (W2) — queue / review / cancel the follow-up prompt FIFO. + * + * The Android counterpart of `public/queue.ts`: a bottom sheet that queues a prompt the server injects + * into the PTY the next time Claude goes idle, lists what is already pending, and offers the ONE cancel + * the server actually has — cancel-ALL. There is deliberately no per-item cancel affordance, because + * `DELETE /live-sessions/:id/queue` clears the whole FIFO and pretending otherwise would lie. + * + * ### The composed text is raw keyboard input (invariant #9) + * The field's contents go to [onSend] **verbatim** — this file never trims, normalises, or appends a + * `\r`. `appendEnter` is a FLAG on the request, so the SERVER materialises the carriage return + * byte-identically to a keystroke. The send action is enabled by [QueueViewModel.canSend], which refuses + * only a genuinely EMPTY string; whitespace is legitimate shell input. + * + * ### Untrusted text (plan §8) + * Queued items are round-tripped through the server, so they are rendered as INERT [Text] — no linkify, + * no Markdown, no `AnnotatedString` autolink — exactly like every other server-derived string. So is the + * [QueueNotice.Rejected] message, which is the server's own already-sanitized `error` field. + * + * Sheet presentation / detents / IME behaviour are device-QA (plan §7); the state machine is + * `QueueViewModelTest`. + * + * @param onSend the composed prompt, verbatim. The panel clears its field only after invoking this. + * @param onClearAll `DELETE …/queue` — cancel every pending entry. + * @param onRefresh re-read the queue (used when the live depth says the cached list is stale). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +public fun QueuePanel( + state: QueueUiState, + onSend: (String) -> Unit, + onClearAll: () -> Unit, + onDismissNotice: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onRefresh: () -> Unit = {}, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var draft by remember { mutableStateOf("") } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg16, vertical = Spacing.md12), + verticalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = "排队提示词", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + QueueBadge(depth = state.depth) + } + Text( + text = "Claude 下次空闲时,服务器会按顺序把它们送进终端。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + state.notice?.let { notice -> + QueueNoticeRow(notice = notice, onDismiss = onDismissNotice) + } + + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, // VERBATIM: no trim / normalisation anywhere on this path + label = { Text("要排队的提示词") }, + enabled = !state.isBusy, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 6, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + Button( + onClick = { + onSend(draft) // exactly what the user typed + draft = "" + }, + enabled = !state.isBusy && QueueViewModel.canSend(draft), + ) { Text("加入队列") } + TextButton(onClick = onClearAll, enabled = !state.isBusy && state.depth > 0) { + Text("全部取消") + } + } + + HorizontalDivider() + QueuedItems(state = state, onRefresh = onRefresh) + } + } +} + +/** The pending prompts, inert. A stale list offers a re-read rather than contradicting the badge. */ +@Composable +private fun QueuedItems(state: QueueUiState, onRefresh: () -> Unit) { + if (state.isItemsStale) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = "队列已变化。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onRefresh) { Text("刷新") } + } + return + } + if (state.items.isEmpty()) { + Text( + text = "队列是空的。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = QUEUE_LIST_MAX_HEIGHT), + verticalArrangement = Arrangement.spacedBy(Spacing.xs4), + ) { + itemsIndexed(state.items) { index, item -> + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + Text( + text = "${index + 1}.", + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // INERT: round-tripped through the server, so plain Text only (plan §8). + Text( + text = item, + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +/** + * One honest line per failure. A 429 says so plainly and offers NO retry button — the bucket is shared + * (20/min/IP), so a retry only burns the budget a real enqueue needs. + */ +@Composable +private fun QueueNoticeRow(notice: QueueNotice, onDismiss: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8)) + .padding(Spacing.sm8), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = queueNoticeText(notice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismiss) { Text("知道了") } + } +} + +/** + * App-authored copy for each outcome. [QueueNotice.Rejected] is the ONE case that surfaces a server + * string, and it is displayed inertly and verbatim (the server already sanitized it); a null message + * falls back to app copy rather than printing "null". + */ +internal fun queueNoticeText(notice: QueueNotice): String = when (notice) { + QueueNotice.Full -> "队列已满,请先取消一条。" + QueueNotice.TooLarge -> "提示词太长了,请缩短后重试。" + QueueNotice.Disabled -> "这台主机关闭了排队功能。" + QueueNotice.SessionGone -> "会话已退出,无法排队。" + // No retry offered: the 20/min bucket is shared with every other client of this host. + QueueNotice.RateLimited -> "操作过于频繁(每分钟上限 20 次),请稍后再手动重试。" + QueueNotice.Unreachable -> "无法连接主机,队列状态未知。" + is QueueNotice.Rejected -> notice.message ?: "主机拒绝了这次操作。" +} + +/** Keeps the sheet's buttons reachable no matter how long the queue gets. */ +private val QUEUE_LIST_MAX_HEIGHT = Spacing.xxl24 * 8 // 192dp + +// ── Preview ───────────────────────────────────────────────────────────────────── + +@Preview(name = "QueueBadge") +@Composable +private fun QueueBadgePreview() { + WebTermTheme { + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) { + QueueBadge(depth = 3) + QueueBadge(depth = 0) // renders nothing + QueueBadge(depth = null) // renders nothing + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt b/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt index 7be9f9a..e67d4f0 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt @@ -2,20 +2,35 @@ package wang.yaojia.webterm.components 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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import wang.yaojia.webterm.designsystem.Radius import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.WebTermTheme @@ -96,6 +111,241 @@ internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) { internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean = gateHeld && hasChips +// ── The palette presenter (production state holder) ───────────────────────────── + +/** + * The state holder the terminal screen renders the chips from: one published, immutable + * [chips] snapshot over a [QuickReplyStore]. + * + * Every mutation delegates to the store — which is the CRUD authority and returns the new + * list — and republishes exactly what came back, so the UI can never drift from what was + * persisted (no optimistic local copy to reconcile, and a guarded no-op like a blank `add` + * simply republishes the unchanged list). Nothing is published before [load]: an unloaded + * palette shows no chips rather than flashing the defaults over the user's real list. + * + * Plain (not an `androidx.lifecycle.ViewModel`) so it drives under `runTest` virtual time; + * the screen `remember`s one and loads it in a `LaunchedEffect`. Cross-device sync is out of + * scope (plan §1) — this is one device's palette. + */ +public class QuickReplyPalette(private val store: QuickReplyStore) { + private val _chips = MutableStateFlow>(emptyList()) + + /** The palette in display order; empty until [load] (and after a user "delete all"). */ + public val chips: StateFlow> = _chips.asStateFlow() + + /** Read the persisted palette (defaults on first ever read) and publish it. */ + public suspend fun load() { + _chips.value = store.loadAll() + } + + /** Append [text] (blank is a store-level no-op) and republish. */ + public suspend fun add(text: String) { + _chips.value = store.add(text) + } + + /** Replace the text of [id] and republish (unknown id / blank text → no-op). */ + public suspend fun edit(id: String, text: String) { + _chips.value = store.edit(id, text) + } + + /** Remove [id] and republish (unknown id → no-op). */ + public suspend fun remove(id: String) { + _chips.value = store.remove(id) + } + + /** Reorder [from]→[to] and republish (out-of-range → no-op). */ + public suspend fun move(from: Int, to: Int) { + _chips.value = store.move(from, to) + } +} + +// ── The editor's select / commit reducer (pure, JVM-tested) ───────────────────── + +/** + * The editor's whole mutable state: ONE text field that either appends a new chip + * ([selectedId] null) or rewrites the selected one. Immutable — every transition returns a + * fresh snapshot. + * + * A single field (rather than an inline field per row) is deliberate: a per-row bound field + * would write to the store on every keystroke, i.e. one DataStore round-trip per character. + */ +internal data class QuickReplyEditorState( + /** The chip being rewritten, or null while composing a new one. */ + val selectedId: String? = null, + /** The field's current text, carried VERBATIM into the commit (whitespace preserved). */ + val draft: String = "", +) + +/** What committing the current [QuickReplyEditorState] does. */ +internal enum class QuickReplyCommit { ADD, EDIT, NONE } + +/** Load [chip] into the field for rewriting (its id becomes the commit target). */ +internal fun QuickReplyEditorState.selecting(chip: QuickReplyChip): QuickReplyEditorState = + copy(selectedId = chip.id, draft = chip.text) + +/** Back to "composing a new chip" with an empty field. */ +internal fun QuickReplyEditorState.cleared(): QuickReplyEditorState = + QuickReplyEditorState() + +/** + * A blank draft commits NOTHING (mirrors the store's blank guard, so the button can be + * disabled instead of silently no-op-ing); otherwise ADD when nothing is selected, EDIT when + * a chip is. + */ +internal fun QuickReplyEditorState.commitKind(): QuickReplyCommit = when { + draft.isBlank() -> QuickReplyCommit.NONE + selectedId == null -> QuickReplyCommit.ADD + else -> QuickReplyCommit.EDIT +} + +// ── The palette editor ────────────────────────────────────────────────────────── + +/** + * The editable-palette surface (plan §1 "Quick-reply chips + editable palette"): an + * [AlertDialog] over the store's CRUD. One field composes/rewrites; each row can be selected + * (tap 编辑), reordered (▲/▼) or deleted (✕). + * + * The chip texts are the user's OWN strings — never server data — but they are still rendered + * as plain inert [Text] (no Markdown/autolink), consistent with §8. + * + * Layout/scroll behaviour is device-QA (§7); the select/commit decision is the JVM-tested + * [QuickReplyEditorState] reducer. + * + * @param chips the current palette snapshot (from [QuickReplyPalette.chips]). + * @param onAdd / [onEdit] / [onRemove] / [onMove] the store-backed CRUD callbacks. + * @param onDismiss close the editor. + */ +@Composable +public fun QuickReplyPaletteEditor( + chips: List, + onAdd: (String) -> Unit, + onEdit: (id: String, text: String) -> Unit, + onRemove: (id: String) -> Unit, + onMove: (from: Int, to: Int) -> Unit, + onDismiss: () -> Unit, +) { + var state by remember { mutableStateOf(QuickReplyEditorState()) } + // A chip removed while selected must not leave a dangling edit target pointing at a gone id. + // DERIVED, not written back during composition (a composition-time state write is a recomposition + // hazard): the field simply falls back to "adding" and keeps whatever was typed. + val effective = if (state.selectedId != null && chips.none { it.id == state.selectedId }) { + state.copy(selectedId = null) + } else { + state + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("快捷回复") }, + text = { + Column( + modifier = Modifier.heightIn(max = EDITOR_MAX_HEIGHT_DP.dp), + verticalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + QuickReplyEditorField( + state = effective, + onDraftChange = { state = effective.copy(draft = it) }, + onCommit = { + when (effective.commitKind()) { + QuickReplyCommit.ADD -> onAdd(effective.draft) + QuickReplyCommit.EDIT -> onEdit(requireNotNull(effective.selectedId), effective.draft) + QuickReplyCommit.NONE -> Unit + } + state = effective.cleared() + }, + onCancelSelection = { state = effective.cleared() }, + ) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Spacing.xs4), + ) { + if (chips.isEmpty()) { + Text("还没有快捷回复。", style = MaterialTheme.typography.bodySmall) + } + chips.forEachIndexed { index, chip -> + QuickReplyEditorRow( + chip = chip, + isSelected = chip.id == effective.selectedId, + canMoveUp = index > 0, + canMoveDown = index < chips.lastIndex, + onSelect = { state = effective.selecting(chip) }, + onMoveUp = { onMove(index, index - 1) }, + onMoveDown = { onMove(index, index + 1) }, + onRemove = { onRemove(chip.id) }, + ) + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("完成") } }, + ) +} + +/** The single compose/rewrite field + its commit action. */ +@Composable +private fun QuickReplyEditorField( + state: QuickReplyEditorState, + onDraftChange: (String) -> Unit, + onCommit: () -> Unit, + onCancelSelection: () -> Unit, +) { + OutlinedTextField( + value = state.draft, + onValueChange = onDraftChange, + label = { Text(if (state.selectedId == null) "新增内容" else "修改内容") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + TextButton( + onClick = onCommit, + enabled = state.commitKind() != QuickReplyCommit.NONE, + ) { Text(if (state.selectedId == null) "添加" else "保存") } + if (state.selectedId != null) { + TextButton(onClick = onCancelSelection) { Text("取消") } + } + } +} + +/** One palette row: the (inert) text plus select / reorder / delete affordances. */ +@Composable +private fun QuickReplyEditorRow( + chip: QuickReplyChip, + isSelected: Boolean, + canMoveUp: Boolean, + canMoveDown: Boolean, + onSelect: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onRemove: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.xs4), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = chip.text, + style = MaterialTheme.typography.bodyMedium, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onSelect) { Text("编辑") } + TextButton(onClick = onMoveUp, enabled = canMoveUp) { Text("▲") } + TextButton(onClick = onMoveDown, enabled = canMoveDown) { Text("▼") } + TextButton(onClick = onRemove) { Text("✕") } + } +} + +/** Cap on the editor body height so a long palette scrolls inside the dialog. */ +private const val EDITOR_MAX_HEIGHT_DP: Int = 320 + // ── Preview ───────────────────────────────────────────────────────────────────── @Preview(name = "QuickReply (gate held)") @@ -109,3 +359,18 @@ private fun QuickReplyPreview() { ) } } + +@Preview(name = "QuickReply palette editor") +@Composable +private fun QuickReplyPaletteEditorPreview() { + WebTermTheme { + QuickReplyPaletteEditor( + chips = QuickReplyDefaults.chips, + onAdd = {}, + onEdit = { _, _ -> }, + onRemove = {}, + onMove = { _, _ -> }, + onDismiss = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt b/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt index 61e44e9..376444a 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt @@ -113,6 +113,10 @@ private fun TitleLine(row: SessionRow) { ) { StatusBadge(status = row.displayStatus) if (row.isUnread) UnreadDot() + // W2: the pending follow-up depth from `GET /live-sessions`'s `queueLength` (null on a pre-W2 + // server → renders nothing). The badge is the cold/list source; an ATTACHED session's live depth + // comes from the `queue` WS frame instead. + QueueBadge(depth = row.info.queueLength) Text( text = row.title.ifBlank { fallbackLabel(row.info) }, style = MaterialTheme.typography.bodyMedium, @@ -202,7 +206,12 @@ private fun SessionListRowPreview() { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) { SessionListRow( row = SessionRow( - info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L), + info = previewInfo( + status = ClaudeStatus.WORKING, + title = "web-terminal", + lastOutputAt = 2L, + queueLength = 2, + ), displayStatus = DisplayStatus.Working, title = "web-terminal", isUnread = true, @@ -228,6 +237,7 @@ private fun previewInfo( cols: Int = 161, rows: Int = 50, lastOutputAt: Long? = null, + queueLength: Int? = null, ): LiveSessionInfo = LiveSessionInfo( id = UUID.fromString("11111111-2222-4333-8444-555555555555"), createdAt = 1L, @@ -240,4 +250,5 @@ private fun previewInfo( rows = rows, telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L), lastOutputAt = lastOutputAt, + queueLength = queueLength, ) diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/AuthCookieModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/AuthCookieModule.kt new file mode 100644 index 0000000..b1545d6 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/AuthCookieModule.kt @@ -0,0 +1,136 @@ +package wang.yaojia.webterm.di + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import okhttp3.CookieJar +import wang.yaojia.webterm.hostregistry.AuthCookieCipher +import wang.yaojia.webterm.hostregistry.AuthCookieStore +import wang.yaojia.webterm.hostregistry.DataStoreAuthCookieStore +import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore +import wang.yaojia.webterm.tlsandroid.TinkAuthCookieCipher +import wang.yaojia.webterm.transport.AuthCookieJar +import wang.yaojia.webterm.wiring.toRecord +import javax.inject.Singleton + +/** + * The optional `WEBTERM_TOKEN` gate's client boundary (server `src/http/auth.ts`). + * + * Three things are wired here, and all three are required for the gate to work at all: + * 1. **[AuthCookieJar] on the ONE shared `OkHttpClient`** (via the [CookieJar] binding [NetworkModule] + * consumes). Both transports share that client, so the `webterm_auth` cookie rides every REST call + * AND the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls too). Without this the + * client runs on `CookieJar.NO_COOKIES` and a token-gated host 401s everything. + * 2. **[AuthCookieStore]** so the credential survives a process restart — encrypted at rest. + * 3. **The [AuthCookieCipher] seam** bridging `:host-registry` (which must not depend on Tink) to + * `:client-tls-android`'s [TinkAuthCookieCipher] (which must not depend on `:host-registry`) — the + * same sibling-bridge pattern [NetworkModule] uses for the mTLS `ClientIdentityProvider`. + * + * ### Fail CLOSED + * [DataStoreAuthCookieStore] takes the cipher as a REQUIRED constructor argument precisely so no wiring + * can persist plaintext by omission. If a real cipher cannot be constructed, this module binds + * [InMemoryAuthCookieStore] instead: memory-only persistence (the user re-authenticates after a restart) + * is the correct answer, and writing a full-shell credential in the clear is not an option. A cipher that + * constructs but later fails to seal is handled the same way one level down — the store persists NOTHING + * and drops any previous blob. + * + * ### Threading + * The graph builds nothing expensive: [TinkAuthCookieCipher] defers all AEAD/keystore work to first use, + * and `AppEnvironment.warmUp()` does the first read off `Main`. The persister side must be non-blocking + * because OkHttp calls a `CookieJar` synchronously on the call thread, so the durable write is handed to + * an app-scoped IO scope. + */ +@Module +@InstallIn(SingletonComponent::class) +public object AuthCookieModule { + + /** + * The durable home of the `webterm_auth` cookie. Shares the ONE Preferences DataStore with the host + * list / last-session pointer ([StorageModule]) — disjoint keys (`authCookiesSealed` vs `hosts` / + * `lastSessionId.`) — but the VALUE here is Tink-AEAD ciphertext, never plaintext. + */ + @Provides + @Singleton + public fun provideAuthCookieStore( + dataStore: DataStore, + @ApplicationContext context: Context, + ): AuthCookieStore { + val cipher = buildAuthCookieCipher(context) + if (cipher == null) { + // Fail closed: memory-only rather than a plaintext credential at rest. + Log.w(TAG, "auth-cookie cipher unavailable — the session cookie will not be persisted") + return InMemoryAuthCookieStore() + } + return DataStoreAuthCookieStore( + dataStore = dataStore, + cipher = cipher, + onCipherFailure = ::reportCipherFailure, + ) + } + + /** + * The ONE jar. Its persister hands each host's changed cookie set to [AuthCookieStore] on an + * app-scoped IO scope — never blocking OkHttp's call thread on DataStore/Tink I/O. The scope lives for + * the process (there is no later moment at which a pending credential write should be abandoned). + */ + @Provides + @Singleton + public fun provideAuthCookieJar(store: AuthCookieStore): AuthCookieJar { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + return AuthCookieJar( + persister = { hostKey, cookies -> + scope.launch { + runCatching { store.replaceHost(hostKey, cookies.map { it.toRecord(hostKey) }) } + .onFailure(::reportPersistFailure) + } + }, + ) + } + + /** What [NetworkModule] installs on the shared client — the same instance as [provideAuthCookieJar]. */ + @Provides + @Singleton + public fun provideCookieJar(jar: AuthCookieJar): CookieJar = jar + + /** + * The 4-line adapter [TinkAuthCookieCipher]'s KDoc prescribes: it matches [AuthCookieCipher]'s shape + * exactly but cannot declare it (sibling modules), so `:app` bridges the two. + * + * Returns null if the cipher cannot even be CONSTRUCTED. Today's implementation defers all keystore + * work to first use and so does not throw here; the guard exists because the alternative to "no + * cipher" must always be "no persistence", never "plaintext persistence". + */ + private fun buildAuthCookieCipher(context: Context): AuthCookieCipher? = + runCatching { + val tink = TinkAuthCookieCipher(context) + object : AuthCookieCipher { + override fun seal(plaintext: String): String = tink.seal(plaintext) + override fun open(sealed: String): String = tink.open(sealed) + } + }.getOrNull() + + /** + * A seal/open failure means a credential was dropped instead of stored (or a stored one could not be + * read). Reported by TYPE only: neither the plaintext nor the ciphertext may reach a log. + */ + private fun reportCipherFailure(error: Throwable) { + Log.w(TAG, "auth-cookie at-rest crypto failed (${error::class.simpleName}) — nothing was persisted") + } + + /** A durable-write failure leaves the cookie in memory only; same logging discipline. */ + private fun reportPersistFailure(error: Throwable) { + Log.w(TAG, "auth-cookie persist failed (${error::class.simpleName}) — cookie kept in memory only") + } + + private const val TAG: String = "WebTermAuthCookie" +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt index ab0165e..ea4cbd6 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt @@ -5,6 +5,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.ConnectionPool +import okhttp3.CookieJar import okhttp3.OkHttpClient import wang.yaojia.webterm.transport.ClientIdentity import wang.yaojia.webterm.transport.ClientIdentityProvider @@ -27,6 +28,11 @@ import javax.inject.Singleton * 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. * + * ### The WEBTERM_TOKEN cookie jar + * The shared client also carries the ONE [CookieJar] provided by [AuthCookieModule], so the optional + * access-token gate applies uniformly to REST and to the WS upgrade. See that module for the at-rest + * (Tink AEAD) custody of the cookie. + * * ### 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 @@ -53,14 +59,24 @@ public object NetworkModule { ClientIdentity(material.sslSocketFactory, material.trustManager) } - /** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */ + /** + * The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool] and + * carrying the ONE [CookieJar] ([AuthCookieModule]). + * + * The cookie jar is what makes the optional `WEBTERM_TOKEN` gate work: a gated host answers pairing + * with `Set-Cookie: webterm_auth=…` and then requires that cookie on every remote route AND on the WS + * upgrade. Because both transports share this client, installing the jar HERE covers both at once + * (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Leaving it at `CookieJar.NO_COOKIES` — as + * this provider did before — makes the whole token feature inert. + */ @Provides @Singleton public fun provideOkHttpClient( identityProvider: ClientIdentityProvider, connectionPool: ConnectionPool, + cookieJar: CookieJar, ): OkHttpClient = - OkHttpClientFactory.create(identityProvider) + OkHttpClientFactory.create(identityProvider, cookieJar) .newBuilder() .connectionPool(connectionPool) .build() diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt index a5aed4d..b46fd44 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt @@ -1,11 +1,19 @@ package wang.yaojia.webterm.di +import com.google.firebase.messaging.FirebaseMessaging import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.suspendCancellableCoroutine +import wang.yaojia.webterm.push.PushRegistrar import wang.yaojia.webterm.push.PushRegistrarTokenSink import wang.yaojia.webterm.push.PushTokenSink +import wang.yaojia.webterm.wiring.HostPushUnregister +import wang.yaojia.webterm.wiring.PushTokenSource +import javax.inject.Singleton +import kotlin.coroutines.resume /** * Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with @@ -13,10 +21,52 @@ import wang.yaojia.webterm.push.PushTokenSink * `Optional` [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). + * + * It also binds the two seams the HOST-REMOVAL path needs + * ([HostPushUnregister] + [PushTokenSource]): `PushRegistrar.unregisterHost` had zero call sites, so a + * removed host kept this device's token and kept pushing for a host the user had dropped. */ @Module @InstallIn(SingletonComponent::class) public interface PushModule { @Binds public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink + + public companion object { + /** + * `DELETE /push/fcm-token` for ONE host — the FIRST caller of [PushRegistrar.unregisterHost], + * which was defined and never invoked, so a removed host kept this device's token forever. + * + * Routed through the registrar rather than a fresh `ApiClient` so the removal inherits its + * already-tested best-effort discipline (a `CancellationException` rethrown, any other failure + * swallowed with the token never logged). + */ + @Provides + @Singleton + public fun provideHostPushUnregister( + registrar: PushRegistrar, + ): HostPushUnregister = HostPushUnregister { host, token -> registrar.unregisterHost(host, token) } + + /** + * The current FCM registration token. Suspends on Firebase's own `Task` rather than blocking, and + * answers `null` for every degraded case (no `google-services.json`, uninitialised `FirebaseApp`, + * a failed fetch) so a push-less build still removes hosts cleanly. The token is returned to the + * caller and NEVER logged (plan §8). + */ + @Provides + @Singleton + public fun providePushTokenSource(): PushTokenSource = PushTokenSource { + suspendCancellableCoroutine { continuation -> + val task = runCatching { FirebaseMessaging.getInstance().token }.getOrNull() + if (task == null) { + continuation.resume(null) + return@suspendCancellableCoroutine + } + task + .addOnSuccessListener { token -> continuation.resume(token?.takeIf { it.isNotEmpty() }) } + .addOnFailureListener { continuation.resume(null) } + .addOnCanceledListener { continuation.resume(null) } + } + } + } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt index 0cf3920..5c03996 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt @@ -12,15 +12,22 @@ 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.DataStoreSessionWatermarkStore import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.LastSessionStore +import wang.yaojia.webterm.hostregistry.SessionWatermarkStore 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."`) 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]. + * The host list ([HostStore], key `"hosts"`), the per-host last-session id ([LastSessionStore], key + * `"lastSessionId."`) and the unread watermarks ([SessionWatermarkStore], key + * `"unreadWatermarks"`) use disjoint keys, so they safely share one DataStore file. Secret material (the + * device cert, the `webterm_auth` cookie) never lives here in the clear — those are the Tink-sealed + * stores in [TlsModule] / [AuthCookieModule]. + * + * Two `DataStore` instances over one file THROW, so every store here takes the single [provideDataStore] + * singleton; a new store means a new KEY, never a new file. */ @Module @InstallIn(SingletonComponent::class) @@ -43,5 +50,16 @@ public object StorageModule { public fun provideLastSessionStore(dataStore: DataStore): LastSessionStore = DataStoreLastSessionStore(dataStore) + /** + * The durable home of the session-list unread watermarks (`sessionId → last-seen ms`). Until this was + * wired the ledger was memory-only, so the unread dot reset on every process death — precisely when + * it matters, since the product premise is walking away and coming back. + */ + @Provides + @Singleton + public fun provideSessionWatermarkStore( + dataStore: DataStore, + ): SessionWatermarkStore = DataStoreSessionWatermarkStore(dataStore) + private const val DATASTORE_NAME: String = "webterm" } diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/ThumbnailModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/ThumbnailModule.kt new file mode 100644 index 0000000..f48f6a5 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/ThumbnailModule.kt @@ -0,0 +1,31 @@ +package wang.yaojia.webterm.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import wang.yaojia.webterm.wiring.CanvasThumbnailRasterizer +import wang.yaojia.webterm.wiring.ThumbnailRasterizer +import javax.inject.Singleton + +/** + * Session-preview boundary (plan §6.7): binds the off-screen rasterizer the thumbnail pipeline paints + * with, so `AppEnvironment.buildThumbnailPipeline(endpoint)` can mint a per-host pipeline and the session + * list finally renders real previews instead of placeholder tiles. + * + * ONE rasterizer for the whole app: its cell metrics are measured once, and it is explicitly safe to + * share across the pipeline's concurrent renders (each `rasterize` copies the metrics `Paint` into a + * local draw `Paint` — see [CanvasThumbnailRasterizer]). + * + * Consumers inject it as `dagger.Lazy` (`AppEnvironment`): constructing it measures a `Paint`, which is an + * `android.graphics` touch that must not happen while the Hilt graph is assembled (android.graphics is + * stubbed under JVM unit tests, and graph assembly happens on `Main`). + */ +@Module +@InstallIn(SingletonComponent::class) +public object ThumbnailModule { + + @Provides + @Singleton + public fun provideThumbnailRasterizer(): ThumbnailRasterizer = CanvasThumbnailRasterizer() +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt index 3a25ba3..22011bd 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt @@ -16,6 +16,8 @@ import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.TinkCertStore import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore +import wang.yaojia.webterm.wire.HttpTransport +import wang.yaojia.webterm.wiring.CertificateRotationScheduler import javax.inject.Singleton /** @@ -78,4 +80,35 @@ public object TlsModule { @Singleton public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher = repository + + /** + * Silent device-certificate rotation (the iOS `CertificateRotationScheduler` counterpart). Android had + * NO rotation at all, so a device certificate simply expired and stranded the app; the scheduler also + * routes an ALREADY-lapsed leaf to `POST /device/:id/recover`, which iOS cannot do. + * + * ### This provider must stay I/O-free + * The shared `OkHttpClient` and the mTLS [HttpTransport] are taken as [dagger.Lazy] and passed as + * SUPPLIERS, so nothing here builds an `SSLSocketFactory` (AndroidKeyStore + Tink reads) while the + * Hilt graph is assembled — that assembly happens on `Main`, and a previous review caught exactly + * that class of ANR. The scheduler resolves both suppliers inside its own `Dispatchers.IO` hop. + * + * `plainTransport` is deliberately NOT passed: its default builds a fresh identity-free client, and + * that separation is the entire point of the recovery path (no TLS terminator will forward an expired + * client certificate, in any `ssl_verify_client` mode). + */ + @Provides + @Singleton + public fun provideCertificateRotationScheduler( + certStore: CertStore, + recordStore: EnrollmentRecordStore, + cacheRefresher: IdentityCacheRefresher, + client: dagger.Lazy, + httpTransport: dagger.Lazy, + ): CertificateRotationScheduler = CertificateRotationScheduler.create( + certStore = certStore, + recordStore = recordStore, + cacheRefresher = cacheRefresher, + sharedClient = { client.get() }, + mtlsTransport = { httpTransport.get() }, + ) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt index 3899926..5f8a290 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt @@ -8,6 +8,8 @@ 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.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -18,12 +20,16 @@ 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.SessionThumbnails import wang.yaojia.webterm.components.continueLastModel +import wang.yaojia.webterm.hostregistry.Host 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 wang.yaojia.webterm.wiring.ThumbnailPipeline +import wang.yaojia.webterm.wiring.sessionThumbnailPipeline import java.util.UUID /** @@ -39,6 +45,21 @@ import java.util.UUID * * 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. + * + * ### Live preview thumbnails (plan §6.7) + * This is the ONE production construction point of the [ThumbnailPipeline]: the active host's endpoint is + * resolved to an [ApiClient][wang.yaojia.webterm.api.routes.ApiClient] and handed to + * [sessionThumbnailPipeline], whose [SessionThumbnails] adapter is threaded into [SessionListScreen] so + * every row shows the session's actual screen instead of a placeholder tile. The fetch is the READ-ONLY + * `GET /live-sessions/:id/preview` — it never attaches, so merely looking at the chooser cannot inflate a + * session's client count or reset its idle clock. The pipeline is pruned to the current row set on every + * poll emission (dead sessions release their bitmaps) and closed when this pane leaves the composition. + * + * ### Pointer context menu (A26) + * The resolved [LayoutMode] is passed down so [SessionListScreen] can wrap each row in a + * [PointerContextMenu][wang.yaojia.webterm.components.PointerContextMenu]; the gate itself is + * [PointerMenuPolicy], evaluated inside that wrapper. The menu's「在当前目录开新会话」routes exactly like + * the terminal toolbar's does. */ @Composable public fun SessionsHome( @@ -70,6 +91,40 @@ public fun SessionsHome( val openNewSession: () -> Unit = { activeHostId?.let { navController.navigate(NavRoutes.terminalNew(it)) } } + // A26 pointer-menu「在当前目录开新会话」— same `attach(null, cwd)` route the terminal toolbar uses. + val openNewSessionInCwd: (String) -> Unit = { cwd -> + activeHostId?.let { navController.navigate(newTerminalRoute(it, cwd)) } + } + + // ── §6.7 live preview thumbnails, for the ACTIVE host ──────────────────────────────────────────── + // The VM exposes only host ids, so resolve the endpoint here (the pipeline needs a per-host ApiClient). + val activeHost by produceState(initialValue = null, key1 = activeHostId) { + // Clear FIRST: produceState keeps the previous value across a key change, so without this a host + // switch would leave the OLD host's endpoint in place and the pipeline would fetch host A's + // session ids against host B until the resolve lands. + value = null + value = activeHostId?.let { id -> + runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == id } }.getOrNull() + } + } + // Constructing the pipeline is cheap and Main-safe: the ApiClient (→ shared OkHttpClient → mTLS + // material) resolves lazily on the first fetch, which runs on the pipeline's own dispatcher. + val pipeline: ThumbnailPipeline? = remember(activeHost) { + activeHost?.let { host -> sessionThumbnailPipeline { env.apiClientFactory.create(host.endpoint) } } + } + DisposableEffect(pipeline) { + onDispose { pipeline?.close() } + } + val thumbnails: SessionThumbnails? = remember(pipeline) { + pipeline?.let { live -> SessionThumbnails { session -> live.thumbnail(session) } } + } + // Release bitmaps for sessions that are no longer live (killed/exited) and superseded lastOutputAt keys, + // so the cache only ever holds screens this list can still show. A render already in flight for a + // just-killed session cannot re-insert its bitmap behind this prune (ThumbnailPipeline.retainOnly closes + // its publish gate); a bitmap outlives its session only for as long as a row keeps asking for it. + LaunchedEffect(pipeline, state.rows) { + pipeline?.retainOnly(state.rows.map { it.info }) + } AdaptiveHome( listPane = { @@ -89,6 +144,9 @@ public fun SessionsHome( onPairHost = { navController.navigate(NavRoutes.PAIRING) }, onEnroll = { navController.navigate(NavRoutes.ENROLL) }, onImportCert = { navController.navigate(NavRoutes.CERT) }, + thumbnails = thumbnails, + layoutMode = mode, + onNewSessionInCwd = openNewSessionInCwd, ) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/FcmService.kt b/android/app/src/main/java/wang/yaojia/webterm/push/FcmService.kt index a2f12bd..6689695 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/push/FcmService.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/push/FcmService.kt @@ -4,7 +4,6 @@ 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 @@ -56,11 +55,23 @@ public class FcmService : FirebaseMessagingService() { if (pushTokenSink.isPresent) pushTokenSink.get().onTokenRefreshed(token) } - @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + /** + * NOT `@RequiresPermission`: [hasNotificationPermission] is only a fast path. The user can revoke + * POST_NOTIFICATIONS between that check and this call (a real TOCTOU — a push arrives on a binder + * thread while the settings UI is open), and `notify` then throws [SecurityException]. Throwing out + * of `onMessageReceived` would crash the FCM service, so the loss is absorbed and logged instead: + * a missed notification must never take the push channel down. Declaring the permission as + * *required* here would also be a lie — the method tolerates its absence. + */ 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) + try { + NotificationManagerCompat.from(this).notify(id, notification) + } catch (e: SecurityException) { + // Never log the payload — it carries the session id and the decision token (§8). + Log.w(TAG, "POST_NOTIFICATIONS revoked between check and notify; dropping notification", e) + } } /** POST_NOTIFICATIONS is a runtime permission on API 33+; below that it is implicitly granted. */ diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt index 15f6871..cae2871 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt @@ -47,6 +47,7 @@ import wang.yaojia.webterm.viewmodels.CertPhase import wang.yaojia.webterm.viewmodels.CertSummaryView import wang.yaojia.webterm.viewmodels.ClientCertUiState import wang.yaojia.webterm.viewmodels.ClientCertViewModel +import wang.yaojia.webterm.wiring.RotationOutcome /** * # ClientCertScreen (A27) — device-certificate (mTLS) management. @@ -78,6 +79,7 @@ public fun ClientCertScreen( viewModel: ClientCertViewModel, modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, + rotationOutcome: RotationOutcome? = rememberRotationOutcome(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() // Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load. @@ -117,9 +119,21 @@ public fun ClientCertScreen( onDismissError = viewModel::clearError, modifier = modifier, onBack = onBack, + rotationOutcome = rotationOutcome, ) } +/** + * The last silent-rotation outcome, from the app graph. `null` outside a Hilt application (a `@Preview`), + * and `null` before the first pass — both render nothing. + */ +@Composable +internal fun rememberRotationOutcome(): RotationOutcome? { + val outcomes = rememberAppEnvironment()?.rotationOutcomes() ?: return null + val outcome by outcomes.collectAsStateWithLifecycle() + return outcome +} + /** Stateless body — pure inputs so a [Preview] and any host can drive it without the SAF/VM machinery. */ @Composable public fun ClientCertScreen( @@ -132,6 +146,7 @@ public fun ClientCertScreen( onDismissError: () -> Unit, modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, + rotationOutcome: RotationOutcome? = null, ) { Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column( @@ -159,6 +174,10 @@ public fun ClientCertScreen( state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) } + // Silent rotation is invisible by design; a FAILING one must not be. Only the states + // the user can act on are rendered — a healthy/not-due pass stays quiet. + RotationStatusRow(rotationOutcome) + PassphraseField( passphrase = passphrase, onPassphraseChange = onPassphraseChange, @@ -220,6 +239,45 @@ private fun InstalledCertCard(summary: CertSummaryView) { } } +/** + * The last silent-rotation pass, when it is something the user can act on. + * + * Quiet for the healthy states ([RotationOutcome.NotDue] / [RotationOutcome.Renewed] / + * [RotationOutcome.Recovered] / [RotationOutcome.NotEnrolled] / [RotationOutcome.BackingOff]) — a + * successful invisible renewal should stay invisible. Loud only for the three the user must know about: + * a lapsed certificate past its recovery window, a host that has no control-plane URL recorded, and a + * repeatedly failing renewal. + * + * The failure text is the outcome's error SHAPE (a class name or `Http(status,code)`), which is all the + * scheduler retains — deliberately never `Throwable.message`, which can embed a control-plane URL, a + * response body or credential material (plan §8). + */ +@Composable +private fun RotationStatusRow(outcome: RotationOutcome?) { + val message = rotationStatusText(outcome) ?: return + Text(text = message, style = MaterialTheme.typography.bodySmall, color = WebTermColors.statusStuck) +} + +/** App-authored copy for the actionable rotation outcomes; null = say nothing. */ +internal fun rotationStatusText(outcome: RotationOutcome?): String? = when (outcome) { + null, + RotationOutcome.NotEnrolled, + RotationOutcome.Renewed, + RotationOutcome.Recovered, + is RotationOutcome.NotDue, + is RotationOutcome.BackingOff, + -> null + + is RotationOutcome.ReEnrollRequired -> + "⚠ 设备证书已过期超过可恢复期,自动续期无法完成。请用「自动获取证书」重新登记本机。" + + is RotationOutcome.NotConfigured -> + "⚠ 未记录控制面地址,无法自动续期证书。请重新执行一次「自动获取证书」。" + + is RotationOutcome.Failed -> + "⚠ 证书自动续期失败(${outcome.stage.name.lowercase()}:${outcome.errorShape})。当前证书仍然有效。" +} + @Composable private fun SummaryRow(label: String, value: String) { Row( diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt index 7b9addb..32069cd 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt @@ -18,6 +18,7 @@ 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.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -38,6 +39,8 @@ 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.text.style.TextOverflow import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat @@ -50,8 +53,10 @@ 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.PairingCopy import wang.yaojia.webterm.viewmodels.PairingUiState import wang.yaojia.webterm.viewmodels.PairingViewModel +import wang.yaojia.webterm.viewmodels.TokenError import wang.yaojia.webterm.viewmodels.WarningTier import wang.yaojia.webterm.viewmodels.needsExplicitAck @@ -71,10 +76,14 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck * - **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`. + * A host behind the optional `WEBTERM_TOKEN` gate cannot answer the probe at all until it is + * authenticated, so the flow has one more step: the masked access-token prompt (see [TokenRequiredStep]). * - * @param viewModel the pairing state machine (nav layer builds it with the real [pairingProber]). + * All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is + * JVM-tested in `PairingViewModelTest` + `PairingTokenFlowTest`. + * + * @param viewModel the pairing state machine (the nav layer builds it with the real prober from + * `AppEnvironment.buildPairingProber()`). * @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. */ @@ -114,6 +123,13 @@ public fun PairingScreen( onRetry = { viewModel.retry() }, onCancel = viewModel::reset, ) + is PairingUiState.TokenRequired -> TokenRequiredStep( + host = s.name, + error = s.error, + onSubmit = viewModel::submitAccessToken, + onCancel = viewModel::reset, + ) + is PairingUiState.TokenSubmitting -> BusyStep(message = "正在验证访问令牌 …") is PairingUiState.Failed -> FailedStep( message = s.message, needsAck = s.tier.needsExplicitAck, @@ -199,6 +215,9 @@ private fun QrScanner(onScanned: (String) -> Unit) { } } +// QrCodeAnalyzer is @ExperimentalGetImage (it reads ImageProxy.image to feed ML Kit); constructing it +// here propagates that requirement to this call site, so the opt-in has to be declared here too. +@androidx.annotation.OptIn(ExperimentalGetImage::class) @Composable private fun CameraPreview(onScanned: (String) -> Unit) { val context = LocalContext.current @@ -292,14 +311,82 @@ private fun tierCopy(tier: WarningTier): Pair = when (tier) { @Composable private fun ProbingStep(host: String) { + BusyStep(message = "正在验证 $host …") +} + +@Composable +private fun BusyStep(message: String) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) { CircularProgressIndicator() - Text("正在验证 $host …", style = MaterialTheme.typography.bodyMedium) + Text(message, style = MaterialTheme.typography.bodyMedium) } } } +// ── Access token (the optional WEBTERM_TOKEN gate) ─────────────────────────────────────────────────── + +/** + * The access-token prompt for a `WEBTERM_TOKEN`-gated host. + * + * The token is a **shell credential**, so the field is masked ([PasswordVisualTransformation]) and typed + * as [KeyboardType.Password] (which also keeps it out of the keyboard's suggestion/learning store), and + * the composition drops it the instant it is handed to the ViewModel — nothing here, and nothing in the + * resulting UI state, retains it. All copy is app-authored and inert (plan §8). + */ +@Composable +private fun TokenRequiredStep( + host: String, + error: TokenError?, + onSubmit: (String) -> Unit, + onCancel: () -> Unit, +) { + var token by remember(host) { mutableStateOf("") } + + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(Spacing.md12), + ) { + Column(modifier = Modifier.padding(Spacing.md12), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) { + Text("需要访问令牌", style = MaterialTheme.typography.titleSmall) + Text( + text = "$host 启用了访问令牌(WEBTERM_TOKEN)。输入令牌后即可继续配对;令牌只用于本次验证,不会显示或记录。", + style = MaterialTheme.typography.bodySmall, + ) + } + } + OutlinedTextField( + value = token, + onValueChange = { token = it }, + label = { Text("访问令牌") }, + singleLine = true, + isError = error != null, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + if (error != null) { + Text( + text = PairingCopy.describeToken(error), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") } + Button( + onClick = { + // Hand it over and forget it in the same breath — never leave the credential in state. + val submitted = token + token = "" + onSubmit(submitted) + }, + enabled = token.isNotBlank(), + modifier = Modifier.weight(1f), + ) { Text("提交") } + } +} + @Composable private fun CertRequiredStep( host: String, diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt index 094abc2..53ac19c 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt @@ -37,10 +37,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import wang.yaojia.webterm.api.models.CommitLogEntry +import wang.yaojia.webterm.api.models.GitLogResult import wang.yaojia.webterm.api.models.PrAvailability import wang.yaojia.webterm.api.models.PrStatus import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectSessionRef +import wang.yaojia.webterm.api.models.SyncState import wang.yaojia.webterm.api.models.WorktreeInfo import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.Stroke @@ -48,8 +50,18 @@ 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.GitChipTone +import wang.yaojia.webterm.viewmodels.GitPanelCopy import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel +import wang.yaojia.webterm.viewmodels.SyncBand +import wang.yaojia.webterm.viewmodels.boundaryIndex +import wang.yaojia.webterm.viewmodels.boundaryLabel +import wang.yaojia.webterm.viewmodels.isUnpushed +import wang.yaojia.webterm.viewmodels.WorktreeRowState import wang.yaojia.webterm.viewmodels.WorktreeViewModel +import wang.yaojia.webterm.viewmodels.headerDirtyChip +import wang.yaojia.webterm.viewmodels.syncBandOf +import wang.yaojia.webterm.viewmodels.worktreeChips import java.net.URI /** @@ -74,8 +86,15 @@ public fun ProjectDetailScreen( val phase by viewModel.phase.collectAsStateWithLifecycle() val prChip by viewModel.prChip.collectAsStateWithLifecycle() val recent by viewModel.recentCommits.collectAsStateWithLifecycle() + val fetch by viewModel.fetchPhase.collectAsStateWithLifecycle() + val worktreeStates by viewModel.worktreeStates.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() - LaunchedEffect(viewModel) { viewModel.load() } + LaunchedEffect(viewModel) { + viewModel.load() + // w6/G7: probe the other worktrees once per opened project (never per tick) — each row's + // failure is isolated, so a bad probe costs one chip, not the panel. + viewModel.probeWorktrees() + } Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column(modifier = Modifier.fillMaxSize()) { @@ -91,7 +110,12 @@ public fun ProjectDetailScreen( detail = current.detail, prChip = prChip, recent = recent, + fetch = fetch, + canFetch = viewModel.canFetch, + onFetch = { scope.launch { viewModel.fetchUpstream() } }, + onDismissFetchBanner = viewModel::clearFetchBanner, worktree = viewModel.worktree, + worktreeStates = worktreeStates, onOpenClaude = onOpenClaude, onViewDiff = onViewDiff, ) @@ -120,6 +144,11 @@ private fun DetailBody( worktree: WorktreeViewModel?, onOpenClaude: (String) -> Unit, onViewDiff: (String) -> Unit = {}, + fetch: ProjectDetailViewModel.FetchPhase = ProjectDetailViewModel.FetchPhase.Idle, + canFetch: Boolean = false, + onFetch: () -> Unit = {}, + onDismissFetchBanner: () -> Unit = {}, + worktreeStates: Map = emptyMap(), ) { Column( modifier = Modifier @@ -139,19 +168,34 @@ private fun DetailBody( 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) + // w6/G3: a count beats a bare dot — "how many uncommitted" is the question the dot dodged. + headerDirtyChip(detail.dirtyCount, detail.dirty)?.let { + Text(text = it, style = WebTermType.metaMono, color = WebTermColors.statusWaiting) + } } Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) + SyncBandCard( + // On a detached HEAD there is no branch to show, so the short sha stands in for it; the + // current worktree row is where the server reports it. + band = syncBandOf(detail.sync, head = detail.worktrees.firstOrNull { it.isCurrent }?.head), + fetch = fetch, + canFetch = canFetch, + onFetch = onFetch, + onDismissFetchBanner = onDismissFetchBanner, + ) + PrChipRow(prChip) if (detail.isGit && worktree != null) { - WorktreeSection(detail = detail, worktree = worktree) + WorktreeSection(detail = detail, worktree = worktree, worktreeStates = worktreeStates) } else { - SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支") + SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size)) if (!detail.isGit) EmptyLine("不是 git 仓库。") else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。") - else for (w in detail.worktrees) WorktreeRow(w, onRemove = null) + else for (w in detail.worktrees) { + WorktreeRow(w, onRemove = null, state = worktreeStateOf(w, detail, worktreeStates)) + } } val running = detail.sessions.filter { !it.exited } @@ -173,6 +217,134 @@ private fun DetailBody( } } +// ── w6/G3: the sync band ───────────────────────────────────────────────────────────────────────── + +/** + * The header sync band: upstream · ↑ahead · ↓behind · Fetch. Stacked (not a 4-column row) because a + * phone is narrow and the mock's desktop row collapses to a column at this width anyway. + * + * **The one design rule:** only [SyncBand.isAllClear] paints green. A stale `↓0` carries the + * "存疑" chip on the NUMBER (↑ never does — it needs only local refs), and a branch with no upstream + * says so in words. Absent facts render nothing at all. + */ +@Composable +private fun SyncBandCard( + band: SyncBand?, + fetch: ProjectDetailViewModel.FetchPhase, + canFetch: Boolean, + onFetch: () -> Unit, + onDismissFetchBanner: () -> Unit, +) { + if (band == null) return + WebTermCard(modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) { + when (band) { + is SyncBand.Detached -> SyncCell(GitPanelCopy.HEAD_CAPTION) { + Text( + text = band.head?.let { "${GitPanelCopy.DETACHED_CHIP} @ $it" } ?: GitPanelCopy.DETACHED_CHIP, + style = WebTermType.metaMono, + color = WebTermColors.statusStuck, + ) + } + + SyncBand.NoUpstream -> { + SyncCell(GitPanelCopy.UPSTREAM_CAPTION) { + Text(text = GitPanelCopy.NO_UPSTREAM_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusStuck) + } + EmptyLine(GitPanelCopy.NO_UPSTREAM_NOTE) + } + + is SyncBand.Tracking -> TrackingCells(band) + } + FetchRow( + band = band, + fetch = fetch, + canFetch = canFetch, + onFetch = onFetch, + onDismissFetchBanner = onDismissFetchBanner, + ) + } + } +} + +@Composable +private fun TrackingCells(band: SyncBand.Tracking) { + SyncCell(GitPanelCopy.UPSTREAM_CAPTION) { + // Upstream is a server string (a remote-tracking ref name) → inert text. + Text(text = band.upstream, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + SyncCell(GitPanelCopy.TO_PUSH_CAPTION) { + Text( + text = GitPanelCopy.aheadChip(band.ahead), + style = WebTermType.metaMono, + color = if (band.ahead > 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (band.isAllClear) { + Text(text = GitPanelCopy.IN_SYNC_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusWorking) + } + } + EmptyLine(if (band.ahead == 0) GitPanelCopy.NOTHING_TO_PUSH_NOTE else GitPanelCopy.aheadNote(band.ahead)) + SyncCell(GitPanelCopy.TO_PULL_CAPTION) { + Text( + text = GitPanelCopy.behindChip(band.behind), + style = WebTermType.metaMono, + // Behind is read off a CACHED remote ref: dim the digit itself when it cannot be trusted. + color = if (band.isStale) WebTermColors.statusStuck else MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (band.isStale) { + Text(text = GitPanelCopy.STALE_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusStuck) + } + } + EmptyLine( + when { + band.isStale && band.lastFetchMs == null -> GitPanelCopy.NEVER_FETCHED_NOTE + band.isStale -> GitPanelCopy.STALE_NOTE + band.behind == 0 -> GitPanelCopy.UP_TO_DATE_NOTE + else -> GitPanelCopy.WAITING_ON_REMOTE_NOTE + }, + ) +} + +@Composable +private fun SyncCell(caption: String, value: @Composable () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + Text( + text = caption, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + value() + } +} + +@Composable +private fun FetchRow( + band: SyncBand, + fetch: ProjectDetailViewModel.FetchPhase, + canFetch: Boolean, + onFetch: () -> Unit, + onDismissFetchBanner: () -> Unit, +) { + val working = fetch == ProjectDetailViewModel.FetchPhase.Working + // Fetch only moves remote-tracking refs — never a pull, never a merge. Disabled on a detached + // HEAD (nothing to compare) and while one is in flight (a second tap is dropped, not queued). + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + OutlinedButton(enabled = canFetch && band.canFetch && !working, onClick = onFetch) { + Text(GitPanelCopy.FETCH) + } + when (fetch) { + ProjectDetailViewModel.FetchPhase.Idle -> if (!band.canFetch) { + EmptyLine(GitPanelCopy.FETCH_DISABLED_DETACHED) + } + ProjectDetailViewModel.FetchPhase.Working -> EmptyLine(GitPanelCopy.FETCH_WORKING) + is ProjectDetailViewModel.FetchPhase.Done -> + BannerLine(GitPanelCopy.FETCH_DONE, WebTermColors.statusWorking, onDismissFetchBanner) + is ProjectDetailViewModel.FetchPhase.Failed -> + BannerLine(fetch.message, WebTermColors.statusStuck, onDismissFetchBanner) + } + } +} + // ── PR + CI chip (link only when https) ────────────────────────────────────────────────────────── @Composable @@ -230,18 +402,30 @@ private fun isHttpsUrl(url: String): Boolean = // ── Worktree section (create / remove / prune) ──────────────────────────────────────────────────── @Composable -private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) { +private fun WorktreeSection( + detail: ProjectDetail, + worktree: WorktreeViewModel, + worktreeStates: Map, +) { val scope = rememberCoroutineScope() val phase by worktree.phase.collectAsStateWithLifecycle() var branch by remember { mutableStateOf("") } var base by remember { mutableStateOf("") } var removeTarget by remember { mutableStateOf(null) } - SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支") + // w6/G5: ALWAYS "Worktrees (n)" — one worktree per session makes the count the point, and a + // section that renames itself to "Branch" at n=1 hides the whole model. + SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size)) if (detail.worktrees.isEmpty()) { EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。") } else { - for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w }) + for (w in detail.worktrees) { + WorktreeRow( + worktree = w, + state = worktreeStateOf(w, detail, worktreeStates), + onRemove = { if (!w.isMain) removeTarget = w }, + ) + } } // New-worktree inline form. @@ -337,31 +521,77 @@ private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) { when (recent) { ProjectDetailViewModel.RecentCommits.Hidden -> Unit ProjectDetailViewModel.RecentCommits.Loading -> { - SectionTitle("最近提交"); EmptyLine("正在读取提交记录…") + SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("正在读取提交记录…") } ProjectDetailViewModel.RecentCommits.Unavailable -> { - SectionTitle("最近提交"); EmptyLine("提交记录不可用。") + SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("提交记录不可用。") } - is ProjectDetailViewModel.RecentCommits.Loaded -> { - SectionTitle("最近提交") - if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。") - else for (commit in recent.result.commits) CommitRow(commit) + is ProjectDetailViewModel.RecentCommits.Loaded -> CommitList(recent.result) + } +} + +/** + * The commit list with w6/G4's unpushed marking: a marked row carries `↑` and the accent, and the + * upstream boundary is drawn EXACTLY ONCE, right after the last marked row — it is the real position + * of `origin/` on this timeline, so it is only drawn when there is an upstream to name. + */ +@Composable +private fun CommitList(log: GitLogResult) { + val boundaryLabel = log.boundaryLabel() + val boundaryIndex = log.boundaryIndex() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + SectionTitle(GitPanelCopy.RECENT_COMMITS) + // The count belongs ON the heading: it qualifies "recent commits", it is not its own statement. + if (log.unpushedCount > 0 && boundaryLabel != null) { + Text( + text = GitPanelCopy.unpushedBadge(log.unpushedCount), + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.primary, + ) } } + if (log.commits.isEmpty()) { + EmptyLine("暂无提交。") + return + } + log.commits.forEachIndexed { index, commit -> + CommitRow(commit) + if (index == boundaryIndex && boundaryLabel != null) UpstreamBoundary(boundaryLabel) + } + if (log.truncated) EmptyLine(GitPanelCopy.truncatedNote(log.commits.size)) +} + +@Composable +private fun UpstreamBoundary(upstream: String) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + // Inert: `upstream` is a server-supplied ref name. + Text(text = upstream, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) + HorizontalDivider( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.outline, + thickness = Stroke.hairline, + ) + } } @Composable private fun CommitRow(commit: CommitLogEntry) { + val unpushed = commit.isUnpushed() Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) { Text( - text = commit.hash.take(7), + text = if (unpushed) GitPanelCopy.UNPUSHED_MARK else " ", + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = commit.hash.take(SHORT_HASH_LENGTH), style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary, ) Text( text = commit.subject, style = WebTermType.metaMono, - color = MaterialTheme.colorScheme.onSurface, + color = if (unpushed) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), @@ -369,25 +599,59 @@ private fun CommitRow(commit: CommitLogEntry) { } } +/** `git log --format=%h` width — the same 7 the rest of the cockpit shows. */ +private const val SHORT_HASH_LENGTH = 7 + // ── Rows / helpers (reused from A23) ──────────────────────────────────────────────────────────────── +/** + * One worktree row: identity + state chips on top, path underneath (on one line the path and the + * chips competed for width and whichever lost got truncated). + * + * The chips come from [worktreeChips], which deliberately has no green "clean" chip: only a fresh + * `↑0 ↓0` earns green, and "no upstream" — the normal state of a fresh worktree branch — is a warning. + */ @Composable -private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) { +private fun WorktreeRow( + worktree: WorktreeInfo, + onRemove: (() -> Unit)?, + state: WorktreeRowState? = null, +) { 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") + for (chip in worktreeChips(worktree, state)) Tag(chip.label, chip.tone) if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") } } Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (state != null && state.sessionCount > 0) { + Text( + text = GitPanelCopy.sessionCount(state.sessionCount), + style = WebTermType.metaMono, + color = WebTermColors.statusWorking, + ) + } } } } +/** + * Per-row git state. The CURRENT worktree's state is the project's own — free, already loaded. Other + * rows need the w6/G7 per-worktree probe (`GET /projects/worktree/state`), which this client does not + * have a route for yet, so they stay unprobed and show no sync chip rather than a guess. + */ +private fun worktreeStateOf( + worktree: WorktreeInfo, + detail: ProjectDetail, + probed: Map, +): WorktreeRowState? { + if (!worktree.isCurrent) return probed[worktree.path] + if (detail.sync == null && detail.dirtyCount == null) return null + return WorktreeRowState(sync = detail.sync, dirtyCount = detail.dirtyCount) +} + @Composable private fun SessionRow(session: ProjectSessionRef) { WebTermCard(modifier = Modifier.fillMaxWidth()) { @@ -413,8 +677,15 @@ private fun ClaudeMdBlock(text: String) { } @Composable -private fun Tag(text: String) { - Text(text = text, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary) +private fun Tag(text: String, tone: GitChipTone = GitChipTone.ACCENT) { + val color = when (tone) { + GitChipTone.NEUTRAL -> MaterialTheme.colorScheme.onSurfaceVariant + GitChipTone.ACCENT -> MaterialTheme.colorScheme.primary + GitChipTone.WARN -> WebTermColors.statusStuck + GitChipTone.DIRTY -> WebTermColors.statusWaiting + GitChipTone.OK -> WebTermColors.statusWorking + } + Text(text = text, style = WebTermType.metaMono, color = color) } @Composable @@ -452,16 +723,27 @@ private fun Centered(content: @Composable () -> Unit) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() } } -@Preview(name = "ProjectDetailScreen — loaded") +@Preview(name = "ProjectDetailScreen — git panel (stale ↓, 9 to push)") @Composable private fun ProjectDetailScreenPreview() { val detail = ProjectDetail( name = "Acme.Web.frontend", path = "/home/dev/acme-web", isGit = true, - branch = "main", + branch = "develop", dirty = true, - worktrees = emptyList(), + dirtyCount = 3, + // The panel's headline case: 9 to push, and a ↓0 that is 19 days old — so NOT green. + sync = SyncState( + upstream = "origin/develop", + ahead = 9, + behind = 0, + lastFetchMs = 1L, + ), + worktrees = listOf( + WorktreeInfo(path = "/home/dev/acme-web", branch = "develop", isMain = true, isCurrent = true), + WorktreeInfo(path = "/home/dev/acme-web/.claude/worktrees/x", branch = "worktree-x"), + ), sessions = emptyList(), hasClaudeMd = true, claudeMd = "# CLAUDE.md\n\nProject instructions…", @@ -471,13 +753,21 @@ private fun ProjectDetailScreenPreview() { detail = detail, prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)), recent = ProjectDetailViewModel.RecentCommits.Loaded( - wang.yaojia.webterm.api.models.GitLogResult( - commits = listOf(CommitLogEntry("abc1234", 1, "Initial commit")), + GitLogResult( + commits = listOf( + CommitLogEntry("553a00c1", 1, "docs(progress): log the leftover fixes", unpushed = true), + CommitLogEntry("1dbed541", 1, "feat(control-panel): web admin UI"), + ), truncated = false, + upstream = "origin/develop", ), ), worktree = null, onOpenClaude = {}, + canFetch = true, + worktreeStates = mapOf( + "/home/dev/acme-web/.claude/worktrees/x" to WorktreeRowState(sync = SyncState(), sessionCount = 1), + ), ) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt index cc9e03c..7207f36 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt @@ -7,6 +7,7 @@ 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.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -15,6 +16,7 @@ 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.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -37,20 +39,31 @@ 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.style.TextOverflow import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.launch +import wang.yaojia.webterm.components.PointerContextMenu import wang.yaojia.webterm.components.SessionListRow import wang.yaojia.webterm.components.SessionThumbnails +import wang.yaojia.webterm.components.sessionRowMenuActions import wang.yaojia.webterm.designsystem.Radius import wang.yaojia.webterm.designsystem.Spacing +import wang.yaojia.webterm.nav.LayoutMode import wang.yaojia.webterm.viewmodels.HostOption +import wang.yaojia.webterm.viewmodels.HostRemovalGateway import wang.yaojia.webterm.viewmodels.SessionListUiState import wang.yaojia.webterm.viewmodels.SessionListViewModel import wang.yaojia.webterm.viewmodels.SessionRow +import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore +import wang.yaojia.webterm.wiring.AppEnvironment import java.util.UUID /** @@ -81,6 +94,20 @@ import java.util.UUID * @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. + * @param layoutMode the adaptive layout from [wang.yaojia.webterm.nav.LayoutPolicy]; it gates the + * per-row pointer context menu (A26) via + * [PointerMenuPolicy][wang.yaojia.webterm.nav.PointerMenuPolicy] inside [PointerContextMenu]. The + * [LayoutMode.STACK] default means a caller that does not pass it gets today's phone behaviour. + * @param onNewSessionInCwd pointer-menu「在当前目录开新会话」→ `attach(null, cwd)` in the row's cwd. + * `null` (the default) drops that entry. + * @param watermarkStore the DURABLE unread-watermark home, installed into [viewModel] before its first + * fetch so the dot survives process death. Defaults to the app-graph store + * ([rememberAppEnvironment]); a test/preview passes an + * [InMemoryUnreadWatermarkStore][wang.yaojia.webterm.viewmodels.InMemoryUnreadWatermarkStore]. + * @param hostRemoval the un-pair path (`HostRemover`), likewise installed into [viewModel]. `null` + * removes the affordance entirely, which is the right behaviour for a surface that cannot un-pair. + * @param onForeground fired on every STARTED transition — production runs the silent certificate + * rotation pass here (the app's landing surface is the reliable "we are in the foreground" edge). */ @Composable public fun SessionListScreen( @@ -92,14 +119,28 @@ public fun SessionListScreen( onImportCert: () -> Unit, modifier: Modifier = Modifier, thumbnails: SessionThumbnails? = null, + layoutMode: LayoutMode = LayoutMode.STACK, + onNewSessionInCwd: ((String) -> Unit)? = null, + watermarkStore: UnreadWatermarkStore? = rememberAppEnvironment()?.unreadWatermarkStore, + hostRemoval: HostRemovalGateway? = rememberAppEnvironment()?.hostRemoval, + onForeground: () -> Unit = rememberCertificateRotationTrigger(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val lifecycleOwner = LocalLifecycleOwner.current + var confirmingRemove by remember { mutableStateOf(false) } // STARTED-scoped poll: fetch→wait→repeat while foregrounded, cancelled on background (plan §6.6). - LaunchedEffect(viewModel, lifecycleOwner) { + // The app-scoped collaborators are installed FIRST, inside the same effect, so the install is + // provably ordered before `poll()`'s first ledger read (a separate LaunchedEffect would only be + // ordered by composition order — true today, but not a property worth depending on). + LaunchedEffect(viewModel, lifecycleOwner, watermarkStore, hostRemoval) { + watermarkStore?.let(viewModel::useWatermarkStore) + hostRemoval?.let(viewModel::useRemovalGateway) lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + // Every foreground return: rotate the device certificate if it is due (idempotent, and + // NotDue costs one store read). Fire-and-forget — it must never delay the list. + onForeground() viewModel.poll() } } @@ -114,6 +155,7 @@ public fun SessionListScreen( onPairHost = onPairHost, onEnroll = onEnroll, onImportCert = onImportCert, + onRemoveHost = if (hostRemoval != null) ({ confirmingRemove = true }) else null, ) }, ) { padding -> @@ -129,8 +171,45 @@ public fun SessionListScreen( onNewSession = onNewSession, onPairHost = onPairHost, thumbnails = thumbnails, + layoutMode = layoutMode, + onNewSessionInCwd = onNewSessionInCwd, + onDismissRemoveError = viewModel::dismissRemoveError, ) } + + if (confirmingRemove) { + RemoveHostConfirmDialog( + hostName = state.hosts.firstOrNull { it.isActive }?.name.orEmpty(), + onConfirm = { + confirmingRemove = false + scope.launch { viewModel.removeActiveHost() } + }, + onDismiss = { confirmingRemove = false }, + ) + } +} + +/** + * Confirm-gate for un-pairing (plan §8): removal deletes this device's push registration ON the host and + * erases the paired record, the last-session pointer, the saved access cookie and the unread watermarks. + * It is not destructive to the host's SESSIONS — they keep running — and the copy says exactly that so + * the user is not guessing. The device certificate is app-wide and deliberately survives. + */ +@Composable +private fun RemoveHostConfirmDialog(hostName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("移除主机") }, + text = { + Text( + // hostName is user-entered at pairing time; rendered inert like every other stored string. + text = "将移除「$hostName」的配对信息、访问凭证与推送注册(该主机不会再向本机推送通知)。" + + "主机上的会话会继续运行;设备证书不会被删除。", + ) + }, + confirmButton = { TextButton(onClick = onConfirm) { Text("移除") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) } // ── Top bar + host menu ────────────────────────────────────────────────────────────────────────────── @@ -143,6 +222,7 @@ private fun SessionListTopBar( onPairHost: () -> Unit, onEnroll: () -> Unit, onImportCert: () -> Unit, + onRemoveHost: (() -> Unit)?, ) { var menuOpen by remember { mutableStateOf(false) } val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话" @@ -162,13 +242,20 @@ private fun SessionListTopBar( onPairHost = { menuOpen = false; onPairHost() }, onEnroll = { menuOpen = false; onEnroll() }, onImportCert = { menuOpen = false; onImportCert() }, + onRemoveHost = onRemoveHost?.let { remove -> { menuOpen = false; remove() } }, ) } }, ) } -/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */ +/** + * The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. + * + * 「移除此主机」acts on the ACTIVE host only — the un-pair has to drop the unread watermarks of that + * host's sessions, and those ids are only known for the host currently listed. It is confirm-gated by the + * caller, and hidden entirely when no removal path is wired. + */ @Composable private fun HostMenu( expanded: Boolean, @@ -178,6 +265,7 @@ private fun HostMenu( onPairHost: () -> Unit, onEnroll: () -> Unit, onImportCert: () -> Unit, + onRemoveHost: (() -> Unit)?, ) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { for (host in hosts) { @@ -193,6 +281,13 @@ private fun HostMenu( // 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS. DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll) DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert) + if (onRemoveHost != null && hosts.any { it.isActive }) { + HorizontalDivider() + DropdownMenuItem( + text = { Text(text = "移除此主机", color = MaterialTheme.colorScheme.error) }, + onClick = onRemoveHost, + ) + } } } @@ -208,32 +303,116 @@ private fun SessionListBody( onNewSession: () -> Unit, onPairHost: () -> Unit, thumbnails: SessionThumbnails?, + layoutMode: LayoutMode, + onNewSessionInCwd: ((String) -> Unit)?, + onDismissRemoveError: () -> Unit, ) { 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) + Column(modifier = Modifier.fillMaxSize()) { + // The un-pair failed and NOTHING was removed (all-or-nothing) — say so above the live list. + if (state.hasRemoveError) RemoveErrorBanner(onDismiss = onDismissRemoveError) + Box(modifier = Modifier.weight(1f)) { + 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, + layoutMode = layoutMode, + onNewSessionInCwd = onNewSessionInCwd, + ) + } + } } } } +/** "nothing was removed" notice — the host is still paired and the action is safe to retry. */ +@Composable +private fun RemoveErrorBanner(onDismiss: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8)) + .padding(Spacing.md12), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = "移除主机失败,未做任何更改。可以再试一次。", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismiss) { Text("知道了") } + } +} + +// ── The Hilt escape hatch for app-scoped collaborators (see TerminalScreen's palette store) ───────────── + +/** + * The app-scoped [AppEnvironment], resolved from the Hilt `SingletonComponent` — the standard escape + * hatch for a Composable, which cannot take constructor injection. Returns `null` outside a Hilt + * application (a `@Preview` or a plain-Compose test host), so those surfaces degrade to no durable + * watermarks and no un-pair affordance instead of crashing. + * + * Resolving it is `Main`-safe: every heavy collaborator inside is behind `dagger.Lazy` and the instance + * already exists by the time any screen composes (`MainActivity` field-injects it in `onCreate`). + */ +@Composable +internal fun rememberAppEnvironment(): AppEnvironment? { + val application = LocalContext.current.applicationContext + return remember(application) { + runCatching { + EntryPointAccessors + .fromApplication(application, AppEnvironmentEntryPoint::class.java) + .appEnvironment() + }.getOrNull() + } +} + +/** + * The default `onForeground` action: run one silent certificate-rotation pass if due. A no-op outside a + * Hilt application. Fire-and-forget by construction — [AppEnvironment.runCertificateRotationIfDue] + * launches on its own app-scoped IO scope, so nothing here can delay the session list. + */ +@Composable +internal fun rememberCertificateRotationTrigger(): () -> Unit { + val env = rememberAppEnvironment() + return remember(env) { { env?.runCertificateRotationIfDue() } } +} + +/** Reads the app-scoped [AppEnvironment] for [rememberAppEnvironment]. */ +@EntryPoint +@InstallIn(SingletonComponent::class) +internal interface AppEnvironmentEntryPoint { + public fun appEnvironment(): AppEnvironment +} + @Composable private fun SessionList( state: SessionListUiState, onOpen: (UUID) -> Unit, onKill: (UUID) -> Unit, thumbnails: SessionThumbnails?, + layoutMode: LayoutMode, + onNewSessionInCwd: ((String) -> Unit)?, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -244,7 +423,14 @@ private fun SessionList( item(key = "load-error") { LoadErrorBanner() } } items(items = state.rows, key = { it.id.toString() }) { row -> - SwipeToKillRow(row = row, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails) + SwipeToKillRow( + row = row, + onOpen = onOpen, + onKill = onKill, + thumbnails = thumbnails, + layoutMode = layoutMode, + onNewSessionInCwd = onNewSessionInCwd, + ) } } } @@ -253,6 +439,11 @@ private fun SessionList( * 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. + * + * On a large screen the row is ALSO wrapped in [PointerContextMenu] (A26), giving mouse/trackpad users the + * same three actions without a swipe: 打开会话 / 在当前目录开新会话 / 终止会话. The wrapper is a transparent + * pass-through whenever [PointerMenuPolicy][wang.yaojia.webterm.nav.PointerMenuPolicy] says no, so the + * phone path is byte-for-byte unchanged. */ @Composable private fun SwipeToKillRow( @@ -260,6 +451,8 @@ private fun SwipeToKillRow( onOpen: (UUID) -> Unit, onKill: (UUID) -> Unit, thumbnails: SessionThumbnails?, + layoutMode: LayoutMode, + onNewSessionInCwd: ((String) -> Unit)?, ) { val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> @@ -276,7 +469,17 @@ private fun SwipeToKillRow( enableDismissFromStartToEnd = false, backgroundContent = { KillBackground() }, ) { - SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails) + PointerContextMenu( + mode = layoutMode, + actions = sessionRowMenuActions( + row = row, + onOpen = onOpen, + onNewSessionInCwd = onNewSessionInCwd, + onKill = onKill, + ), + ) { + SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails) + } } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt index cef28ca..ebc2b50 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt @@ -3,8 +3,9 @@ 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.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,6 +26,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -39,23 +41,45 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import wang.yaojia.webterm.components.AwayDigestView +import wang.yaojia.webterm.components.BannerModel +import wang.yaojia.webterm.components.DataStoreQuickReplyStore 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.QueueBadge +import wang.yaojia.webterm.components.QueuePanel +import wang.yaojia.webterm.components.QuickReply +import wang.yaojia.webterm.components.QuickReplyPalette +import wang.yaojia.webterm.components.QuickReplyPaletteEditor +import wang.yaojia.webterm.components.QuickReplyStore 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.terminalview.TerminalCopyResult +import wang.yaojia.webterm.viewmodels.QueueGateway +import wang.yaojia.webterm.viewmodels.QueueUiState +import wang.yaojia.webterm.viewmodels.QueueViewModel import wang.yaojia.webterm.wire.GateKind import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wiring.RetainedSessionHolder import wang.yaojia.webterm.wiring.TerminalSessionControllerImpl +import wang.yaojia.webterm.wiring.UiConfigGate +import java.util.UUID /** * The arguments to open a NEW session — the pure output of the "new session in current cwd" decision. @@ -80,10 +104,11 @@ public object NewSessionInCwd { * * 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). + * 「快捷回复」editor + "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) + + * telemetry chips ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with + * the privacy cover), the [QuickReply] chip row (A25 — floats only while a gate is held), 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. @@ -106,8 +131,16 @@ public object NewSessionInCwd { * 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). + * + * ### Latest-writer-wins resize (plan §6.4) — who pushes what + * The grid itself is measured and pushed by `:terminal-view` (`RemoteTerminalHostView.onSizeChanged` → + * `TerminalResizeDriver` → `RemoteTerminalSession.updateSize` → `Resize` on the wire), so this screen does + * NOT compute cols×rows and must not re-assert a size the view just pushed. What the view cannot see is + * "this device became the active one again": `ON_RESUME` and a window-focus GAIN therefore call + * `notifyForegrounded`, which re-sends the engine's remembered dims unconditionally (no dedup) and so wins + * the shared PTY size back from whichever device wrote last. The size-changed outlet is only forwarded + * while the session is NOT connected, where the same call is the connect-now nudge — see + * [TerminalReclaimPolicy] for the decision table (JVM-tested). * * @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]. @@ -116,6 +149,14 @@ public object NewSessionInCwd { * 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. + * @param quickReplyStore the A25 palette store. Defaults to the app-graph one + * ([rememberAppQuickReplyStore] — the SAME `DataStore` singleton every other store uses, so + * the palette really persists); a test/preview passes an + * [InMemoryQuickReplyStore][wang.yaojia.webterm.components.InMemoryQuickReplyStore]. + * @param queueGateway the W2 follow-up-queue seam for this host. `null` hides the queue affordance + * entirely (a surface with no way to reach the host cannot queue). + * @param uiConfigGate the host's `GET /config/ui` policy. `null` leaves `allowAutoMode` at its + * fail-closed `false`, so "approve + auto-accept" is never offered on an unknown host. */ @Composable public fun TerminalScreen( @@ -127,6 +168,9 @@ public fun TerminalScreen( modifier: Modifier = Modifier, onWarmUp: suspend () -> Unit = {}, onDigestExpand: () -> Unit = {}, + quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(), + queueGateway: QueueGateway? = rememberAppQueueGateway(endpoint), + uiConfigGate: UiConfigGate? = rememberAppEnvironment()?.uiConfigGate, ) { val context = LocalContext.current val activity = remember(context) { context.findActivity() } @@ -192,13 +236,15 @@ public fun TerminalScreen( val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, activity, controller) { val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_STOP -> { + 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) + event == Lifecycle.Event.ON_START -> covered = false + // §6.4 reclaim: the engine re-sends its remembered dims with NO dedup, so the returning + // device takes the shared PTY size back even though nothing about it changed. + TerminalReclaimPolicy.reclaimsOnLifecycle(event) -> controller.notifyForegrounded(null, null) else -> Unit } } @@ -210,10 +256,47 @@ public fun TerminalScreen( val windowInfo = LocalWindowInfo.current LaunchedEffect(windowInfo, controller) { snapshotFlow { windowInfo.isWindowFocused }.collect { focused -> - if (focused) controller.notifyForegrounded(null, null) + if (TerminalReclaimPolicy.reclaimsOnWindowFocus(focused)) controller.notifyForegrounded(null, null) } } + // `GET /config/ui` → may this host be put into auto-accept-edits? Fetched once per host and pushed + // into the gate presenter, which drops an ACCEPT_EDITS decision while it is false. FAIL CLOSED: a + // failed/absent fetch never calls this, so the presenter keeps its `false` default and the sheet's + // third affordance is not offered at all (see the plan-gate branch below). + LaunchedEffect(gateViewModel, uiConfigGate, endpoint) { + val gate = uiConfigGate ?: return@LaunchedEffect + // Off-Main: the first touch resolves the shared mTLS client (AndroidKeyStore/Tink reads). + val allowed = withContext(Dispatchers.IO) { gate.allowsAutoMode(endpoint) } + if (allowed) gateViewModel.setAutoModeAllowed(true) + } + + // W2 follow-up queue. The presenter is composition-scoped (it holds only HTTP-derived state), while the + // authoritative DEPTH rides the retained gate presenter's control mailbox — so a rotation cannot leak a + // mailbox or lose the badge. A freshly spawned session has no id until the server adopts one. + val queueVm = remember(queueGateway) { queueGateway?.let { QueueViewModel(it) } } + // Remembered UNCONDITIONALLY (a `remember` inside an elvis branch is a conditional composable call) + // so the stand-in for "no queue on this host" is a stable flow, not a per-recomposition instance. + val noQueueState = remember { MutableStateFlow(QueueUiState()) } + val queueUi by (queueVm?.uiState ?: noQueueState).collectAsStateWithLifecycle() + val liveSessionId = remember(sessionId, gateUi.sessionId) { + (sessionId ?: gateUi.sessionId)?.let { raw -> runCatching { UUID.fromString(raw) }.getOrNull() } + } + // The live `queue` frame is the authoritative depth; hand it to the panel presenter so its badge and + // its item list can never disagree with what the server just broadcast. + LaunchedEffect(queueVm, gateUi.queueDepth) { + gateUi.queueDepth?.let { depth -> queueVm?.onServerDepth(depth) } + } + var showQueuePanel by remember { mutableStateOf(false) } + val queueScope = rememberCoroutineScope() + + // A25 quick-reply palette: one remembered presenter over the injected store, loaded once per store. + val palette = remember(quickReplyStore) { QuickReplyPalette(quickReplyStore) } + val quickReplyChips by palette.chips.collectAsStateWithLifecycle() + val paletteScope = rememberCoroutineScope() + var showPaletteEditor by remember { mutableStateOf(false) } + LaunchedEffect(palette) { palette.load() } + val requestNewSession: () -> Unit = { onNewSessionInCwd(NewSessionInCwd.requestFor(cwd)) } // A slow wall-clock tick so the telemetry chips grey out as the last frame ages (§ staleness). @@ -224,16 +307,62 @@ public fun TerminalScreen( } } + // Copy-out (first-party selection layer in `:terminal-view`). The view handle is captured per + // `generation` so a real-background rebuild never leaves a stale reference behind. + var remoteTerminalView by remember { mutableStateOf(null) } + var isSelecting by remember { mutableStateOf(false) } + var copyNotice by remember { mutableStateOf(null) } + LaunchedEffect(copyNotice) { + if (copyNotice != null) { + delay(COPY_NOTICE_MS) + copyNotice = null + } + } + Column(modifier = modifier.fillMaxSize()) { - TerminalToolbar(title = title, onNewSessionInCwd = requestNewSession) + TerminalToolbar( + title = title, + onNewSessionInCwd = requestNewSession, + onEditQuickReplies = { showPaletteEditor = true }, + queueDepth = gateUi.queueDepth, + onOpenQueue = if (queueVm != null && liveSessionId != null && queueUi.isSupported) { + { + showQueuePanel = true + queueScope.launch { queueVm.refresh(liveSessionId) } + } + } else { + null + }, + // Shown only while a selection is live: an OEM whose floating toolbar misbehaves still has a + // reachable Copy, and the action is ALWAYS an explicit user gesture (§8 — terminal bytes reach + // the clipboard on no other path). + onCopySelection = if (isSelecting) { + { + copyNotice = copyResultText(remoteTerminalView?.copySelection()) + remoteTerminalView?.clearSelection() + } + } else { + null + }, + ) + copyNotice?.let { notice -> InlineNotice(text = notice) } 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. + // The W1 `status.preview` MUST be passed: without it the card is a name-only bar and the user is + // approving a `Bash` call without seeing the command (the "blind approval" defect). gateUi.gate?.takeIf { it.kind == GateKind.TOOL }?.let { toolGate -> - GateBanner(gate = toolGate, onDecide = gateViewModel::decide) + GateBanner(gate = toolGate, onDecide = gateViewModel::decide, preview = gateUi.preview) + } + // A plan gate on a host that FORBIDS auto-accept-edits degrades to the same two-way card: only + // 批准 (approve + review) and 拒绝 (keep planning) are real affordances there, so offering the + // three-way sheet would advertise a decision the host will not honour. The authoritative drop + // lives in `GateViewModel.decide`; this is the "don't offer it" half. + gateUi.gate?.takeIf { it.kind == GateKind.PLAN && !gateUi.allowAutoMode }?.let { planGate -> + GateBanner(gate = planGate, onDecide = gateViewModel::decide, preview = gateUi.preview) } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { key(generation) { @@ -246,38 +375,150 @@ public fun TerminalScreen( 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) } + // The grid for this size has ALREADY been measured and pushed by the view layer + // (TerminalResizeDriver → RemoteTerminalSession.updateSize → `Resize`) by the time + // this fires, so re-asserting it here would only double the frame. Forward it to + // the engine solely while disconnected, where the call is the connect-now nudge + // (§6.4). bannerState is read at CALL time — a value captured in this factory + // lambda would be frozen at first composition. + remoteView.onViewSizeChanged = { _, _ -> + if (TerminalReclaimPolicy.nudgesOnViewSizeChange(holder.bannerState.value)) { + controller.notifyForegrounded(null, null) + } + } + // Copy-out: mirror the selection state up so the toolbar can offer a Copy action. + // Stock Termux selection stays unreachable (its ActionMode dereferences the absent + // TerminalSession) — this is the first-party layer, and nothing here calls + // startTextSelectionMode. + remoteView.onSelectionChanged = { selecting -> isSelecting = selecting } + remoteTerminalView = remoteView controller.remoteSession.attachView(remoteView) - remoteView.hostView() + // The host view IS the View to compose: :terminal-view now exposes its own + // `RemoteTerminalHostView` (a FrameLayout that owns focus + the IME contract and + // keeps the final stock Termux view away from its session-dereferencing paths), so + // no reflection is needed to avoid naming an `implementation`-scoped Termux type. + remoteView.terminalView }, // 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() }, + onRelease = { + remoteTerminalView = null + isSelecting = false + controller.remoteSession.detachView() + }, ) } if (covered) PrivacyCover() } + // A25: the quick-reply chips float directly above the key-bar while a gate is held — the moment + // Claude is waiting is exactly when a one-tap canned reply is useful. Each tap sends the chip's + // text VERBATIM through the same ordered send pump as typing (invariant #1/#9). + QuickReply( + chips = quickReplyChips, + gateHeld = gateUi.gate != null, + onSend = controller::sendInput, + ) // 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 = {}) + // The editable palette (A25). Every mutation goes through the store (the CRUD authority) and the + // presenter republishes what came back, so the chips above can never drift from what was persisted. + if (showPaletteEditor) { + QuickReplyPaletteEditor( + chips = quickReplyChips, + onAdd = { text -> paletteScope.launch { palette.add(text) } }, + onEdit = { id, text -> paletteScope.launch { palette.edit(id, text) } }, + onRemove = { id -> paletteScope.launch { palette.remove(id) } }, + onMove = { from, to -> paletteScope.launch { palette.move(from, to) } }, + onDismiss = { showPaletteEditor = false }, + ) } + + // Plan gate → a three-way ModalBottomSheet overlay, but ONLY on a host that permits auto-accept-edits + // (`GET /config/ui`); otherwise the two-way card above stands in. 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 && gateUi.allowAutoMode }?.let { planGate -> + PlanGateSheet( + gate = planGate, + onDecide = gateViewModel::decide, + onDismiss = {}, + preview = gateUi.preview, + ) + } + + // W2 queue panel. Every write is one attempt with a typed outcome — the presenter never auto-retries, + // least of all a 429 (the 20/min bucket is shared with every other client of this host). + if (showQueuePanel && queueVm != null && liveSessionId != null) { + QueuePanel( + state = queueUi, + onSend = { text -> queueScope.launch { queueVm.enqueue(liveSessionId, text) } }, + onClearAll = { queueScope.launch { queueVm.clearAll(liveSessionId) } }, + onDismissNotice = queueVm::dismissNotice, + onDismiss = { showQueuePanel = false }, + onRefresh = { queueScope.launch { queueVm.refresh(liveSessionId) } }, + ) + } +} + +/** How long a copy-outcome notice stays on screen. */ +private const val COPY_NOTICE_MS: Long = 2_000L + +/** + * App-authored copy for a [TerminalCopyResult]. The copied TEXT is never rendered or logged (§8) — only + * whether the copy happened. + */ +internal fun copyResultText(result: TerminalCopyResult?): String = when (result) { + TerminalCopyResult.COPIED -> "已复制到剪贴板" + TerminalCopyResult.NOTHING_SELECTED -> "没有选中内容" + TerminalCopyResult.NOTHING_TO_COPY -> "选中的区域是空白" + TerminalCopyResult.CLIPBOARD_UNAVAILABLE -> "系统剪贴板拒绝了这次复制" + null -> "终端还没准备好" +} + +/** A short-lived inert status line (copy outcome). Fixed app copy — never a server or exception string. */ +@Composable +private fun InlineNotice(text: String) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Spacing.md12, vertical = Spacing.xs4), + ) + } +} + +/** + * The W2 queue seam for [endpoint], resolved from the Hilt graph (a Composable cannot be + * constructor-injected). `null` outside a Hilt application, which correctly hides the affordance in a + * `@Preview`. Remembered per endpoint so a recomposition does not mint a new presenter. + */ +@Composable +internal fun rememberAppQueueGateway(endpoint: HostEndpoint): QueueGateway? { + val env = rememberAppEnvironment() + return remember(env, endpoint) { env?.buildQueueGateway(endpoint) } } /** 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. */ +/** + * Toolbar: the inert (already-sanitized) session title, the quick-reply palette editor and the + * new-session-in-cwd action. The editor lives HERE rather than on the chip row because the row only + * floats while a gate is held (and hides itself when the palette is empty) — an editor reachable only + * from the row could never be used to create the first chip. + */ @Composable -private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) { +private fun TerminalToolbar( + title: String, + onNewSessionInCwd: () -> Unit, + onEditQuickReplies: () -> Unit, + queueDepth: Int? = null, + onOpenQueue: (() -> Unit)? = null, + onCopySelection: (() -> Unit)? = null, +) { Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -292,6 +533,15 @@ private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + // Only while text is selected — the clipboard write is always an explicit gesture (§8). + onCopySelection?.let { copy -> TextButton(onClick = copy) { Text("复制") } } + onOpenQueue?.let { open -> + TextButton(onClick = open) { + Text("排队") + QueueBadge(depth = queueDepth, modifier = Modifier.padding(start = Spacing.xs4)) + } + } + TextButton(onClick = onEditQuickReplies) { Text("快捷回复") } TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") } } } @@ -340,17 +590,65 @@ private tailrec fun Context.findActivity(): Activity? = when (this) { } /** - * The [android.view.View] to add to the Compose tree — the Termux `TerminalView` behind - * [RemoteTerminalView]. + * WHICH app-side events must call `SessionEngine.notifyForegrounded` for the §6.4 latest-writer-wins + * reclaim — the pure, JVM-tested half of the device-switch resize path. * - * 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 }`. + * A shared PTY has exactly ONE size, so the device you are actively using has to re-assert its dims to + * take that size back from whichever device wrote last. Two things make this subtler than "resend on every + * event": + * - **The view layer already owns the grid.** `RemoteTerminalHostView.onSizeChanged` → + * `TerminalResizeDriver` → `RemoteTerminalSession.updateSize` measures and pushes the real cols×rows, + * deduping sub-cell layout churn so a rotation costs exactly one `Resize` frame. Calling the engine on + * the SAME size-changed event would double every one of those frames (and re-SIGWINCH the shell). + * - **The engine call has no dedup.** `notifyForegrounded` re-sends the remembered dims unconditionally — + * which is precisely what the reclaim needs, and precisely why it must not be attached to layout churn. + * + * So the engine call is reserved for what the view cannot observe (becoming the active device again: + * `ON_RESUME`, a window-focus GAIN) plus the disconnected case, where the same call is the + * connect-now-without-resetting-the-ladder nudge. */ -private fun RemoteTerminalView.hostView(): View = - RemoteTerminalView::class.java.getMethod("getTerminalView").invoke(this) as View +internal object TerminalReclaimPolicy { + + /** `ON_RESUME` (pane-show / device-switch) is the only lifecycle event that reclaims. */ + fun reclaimsOnLifecycle(event: Lifecycle.Event): Boolean = event == Lifecycle.Event.ON_RESUME + + /** Focus GAIN reclaims; a blur must never resize (the v0.4 min→latest-writer pivot removed that). */ + fun reclaimsOnWindowFocus(focused: Boolean): Boolean = focused + + /** + * A view size change reaches the engine only while a connect is pending/retrying — there the call is + * the connect-now nudge. Connected ([BannerModel.Hidden]) the view driver has already pushed this + * exact grid; terminally failed/exited there is no reconnect owed. + */ + fun nudgesOnViewSizeChange(banner: BannerModel): Boolean = + banner is BannerModel.Connecting || banner is BannerModel.Reconnecting +} + +/** + * The app-graph quick-reply store: a [DataStoreQuickReplyStore] over the SAME + * `DataStore` singleton the host/last-session stores use (disjoint keys, one file — two + * `DataStore` instances over one file would throw). + * + * `TerminalScreen` is a Composable, so it cannot take the store by constructor injection; the store is + * pulled off the Hilt `SingletonComponent` through an `@EntryPoint` instead (the standard Hilt escape + * hatch for exactly this case) and kept as a default parameter value so a test/preview can still pass an + * in-memory store. Nothing here widens the DI graph — the provider already exists in `di/StorageModule`. + */ +@Composable +internal fun rememberAppQuickReplyStore(): QuickReplyStore { + val application = LocalContext.current.applicationContext + return remember(application) { + DataStoreQuickReplyStore( + EntryPointAccessors + .fromApplication(application, QuickReplyStoreEntryPoint::class.java) + .preferencesDataStore(), + ) + } +} + +/** Reads the app-scoped Preferences `DataStore` provider for [rememberAppQuickReplyStore]. */ +@EntryPoint +@InstallIn(SingletonComponent::class) +internal interface QuickReplyStoreEntryPoint { + public fun preferencesDataStore(): DataStore +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt index 9029880..7f0f61f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt @@ -10,13 +10,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import wang.yaojia.webterm.session.Adopted 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.Queued import wang.yaojia.webterm.session.SessionEvent import wang.yaojia.webterm.session.Telemetry +import wang.yaojia.webterm.wire.ApprovalPreview import wang.yaojia.webterm.wire.ClientMessage import wang.yaojia.webterm.wire.GateKind import wang.yaojia.webterm.wire.StatusTelemetry @@ -58,6 +61,19 @@ import wang.yaojia.webterm.wiring.TerminalSessionController * 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. + * + * ### `allowAutoMode` fails CLOSED (`GET /config/ui`) + * `approve.mode="acceptEdits"` puts Claude into auto-accept-edits, which an operator can switch off + * host-side (`ALLOW_AUTO_MODE=0` → `GET /config/ui` `{allowAutoMode:false}`). [decide] therefore drops + * [GateDecision.ACCEPT_EDITS] unless [setAutoModeAllowed] has been told the host permits it — the + * default is `false`, so a host that is merely unreachable can never *widen* what the phone may approve. + * This is the authoritative half; the surface additionally stops OFFERING the affordance. + * + * ### It also carries the two cockpit signals nothing else owns + * The `queue` frame ([Queued]) and the adopted session id ([Adopted]) arrive on this same control + * mailbox and used to be discarded here. They live in [GateUiState] because this VM is the retained, + * rotation-surviving cockpit consumer (`RetainedSessionHolder`): a composition-scoped collector would + * both lose them on rotation and leak a fresh orphaned mailbox each time. */ public class GateViewModel( private val controller: TerminalSessionController, @@ -105,14 +121,29 @@ public class GateViewModel( 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. + // W2: the server's authoritative pending-prompt depth (never a local count). + is Queued -> _uiState.value = _uiState.value.copy(queueDepth = event.length.coerceAtLeast(0)) + // The server-issued id — the only way a freshly spawned session learns what to queue against. + is Adopted -> _uiState.value = _uiState.value.copy(sessionId = event.sessionId) + // The session ended: a held gate is no longer decidable and there is no queue to show. + is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null, queueDepth = null) + else -> Unit // Output / Connection are not cockpit concerns. } } + /** + * Record whether this host permits `approve.mode="acceptEdits"` (`GET /config/ui` + * `{allowAutoMode}`). Called by the screen after a SUCCESSFUL fetch only — a failed fetch must leave + * the fail-closed `false` in place, because the permissive default is the dangerous one. + */ + public fun setAutoModeAllowed(allowed: Boolean) { + _uiState.value = _uiState.value.copy(allowAutoMode = allowed) + } + private fun onGate(gate: GateState?) { - _uiState.value = _uiState.value.copy(gate = gate) + // The preview is bound to the gate it describes: it arrives with it and is cleared with it, so + // a resolved gate's command/diff can never sit under the NEXT gate's buttons. + _uiState.value = _uiState.value.copy(gate = gate, preview = gate?.preview) 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) { @@ -133,8 +164,12 @@ public class GateViewModel( * 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 state = _uiState.value + val held = state.gate ?: return // canDecide line 1: no gate held → drop. + if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop. + // The host's own policy outranks the phone: auto-accept-edits is refused unless a successful + // `GET /config/ui` said it is allowed (fail closed — see the class doc). + if (decision == GateDecision.ACCEPT_EDITS && !state.allowAutoMode) return val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop. controller.decideGate(epoch, message) } @@ -152,14 +187,49 @@ public enum class GateDecision { REJECT, } +/** + * W1 — strip everything that could make untrusted preview text behave like anything other than text: + * every C0 control char except `\n`/`\t`, DEL, and the C1 range. ESC in particular must never reach a + * renderer — and `\n` must survive, because a shell command's line structure IS the content. + * + * The server already sanitizes (`src/http/approval-preview.ts`); this is the client's defence in + * depth, because the gate surfaces are the one place untrusted bytes sit under an approve button. + */ +public fun inertPreviewText(raw: String): String = + raw.filterNot { ch -> + val code = ch.code + (code < 0x20 && ch != '\n' && ch != '\t') || code == 0x7F || (code in 0x80..0x9F) + } + +/** The sanitized command text of a [ApprovalPreview.Command], or null when nothing legible remains. */ +public fun ApprovalPreview.Command.inertText(): String? = inertPreviewText(text).takeIf { it.isNotBlank() } + /** 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, + /** What the held gate would run, or `null` when the server sent no preview (a NORMAL state). */ + val preview: ApprovalPreview? = 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, + /** + * W2 pending prompt-queue depth from the live `queue` frame — `null` = unknown (no frame yet, or the + * session exited), `0` = empty. The badge renders only for a positive value. + */ + val queueDepth: Int? = null, + /** + * The SERVER-issued session id (`attached`/`Adopted`), or `null` before the first attach. The queue + * routes are addressed by it, so a freshly spawned session (opened with a null id) can only be + * queued to once this arrives. + */ + val sessionId: String? = null, + /** + * Does this host permit `approve.mode="acceptEdits"` (`GET /config/ui` `{allowAutoMode}`)? + * **Defaults to `false` and is only ever raised by a SUCCESSFUL fetch** — fail closed. + */ + val allowAutoMode: Boolean = false, ) /** diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt index 72871e6..5fedf1b 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt @@ -8,14 +8,11 @@ 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 /** @@ -35,13 +32,23 @@ import java.util.UUID * 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"). + * 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** [confirm], [retry] and the + * post-token resume all funnel through the ONE private `performProbe`; for a [WarningTier.TUNNEL] + * host with no installed device cert it 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]. * + * ### The `WEBTERM_TOKEN` gate (server `src/http/auth.ts`) + * A token-gated host answers the probe's unauthenticated `GET /live-sessions` with **401**, which the + * frozen probe taxonomy can only report as [PairingError.HttpOkButNotWebTerminal] ("端口对吗?") — so + * without this flow such a host is impossible to pair AND the copy is misleading. On that ambiguous + * failure the ViewModel asks [PairingProber.requiresAccessToken]; a gated host routes to + * [PairingUiState.TokenRequired], and [submitAccessToken] posts the token (`POST /auth`) so the shared + * cookie jar picks up the `webterm_auth` cookie, then resumes pairing through the same choke point. + * The token is a SHELL credential: it is a parameter, never a field and never part of any UI state. + * * @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). @@ -126,38 +133,102 @@ public class PairingViewModel( runProbe(candidate, acknowledgedPublicRisk) } + /** + * Submit the host's shared access token (`WEBTERM_TOKEN`). Only meaningful from + * [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never post a + * credential to a host the user has moved on from. + * + * [token] is passed straight through to the prober and is NEVER stored: no field, no UI state, no + * log. On acceptance the shared cookie jar holds `webterm_auth` and pairing resumes through the same + * choke point as [confirm]/[retry] (so RULE 4's cert-gate still applies); every other outcome + * re-arms the prompt with a distinguishable reason and NEVER auto-retries (429 included). + */ + public fun submitAccessToken(token: String) { + val state = _uiState.value + if (state !is PairingUiState.TokenRequired) return + if (token.isBlank()) return + val candidate = Candidate(state.endpoint, state.name, state.tier) + val scope = scope ?: return + probeJob?.cancel() + probeJob = scope.launch { + _uiState.value = PairingUiState.TokenSubmitting(candidate.endpoint, candidate.name, candidate.tier) + when (prober.submitAccessToken(candidate.endpoint, token)) { + // Straight into the probe body: RULE 3 is already satisfied by construction (reaching the + // prompt required a probe, which for a public host required the explicit ack — and an ack + // cannot be un-given), while RULE 4's cert-gate lives INSIDE performProbe and re-applies. + TokenSubmission.Accepted -> performProbe(candidate) + TokenSubmission.Rejected -> promptForToken(candidate, TokenError.INVALID) + TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED) + is TokenSubmission.Unreachable -> promptForToken(candidate, TokenError.UNREACHABLE) + TokenSubmission.Unavailable -> promptForToken(candidate, TokenError.UNAVAILABLE) + } + } + } + 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) { + // RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the + // pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe. + if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) { _uiState.value = PairingUiState.Confirming( endpoint = candidate.endpoint, name = candidate.name, - tier = tier, + tier = candidate.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), - ) - } + probeJob = scope.launch { performProbe(candidate) } + } + + /** + * The ONE probe body — reached from [confirm]/[retry] via [runProbe] and from an accepted access + * token. Holds RULE 4 (the cert-gate) so BOTH entry points hit it; RULE 3 (the public-risk ack) is a + * pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns + * [probeJob], which would cancel the coroutine it is called from). + */ + private suspend fun performProbe(candidate: Candidate) { + val tier = candidate.tier + // RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both + // re-hit this same guard. + if (tier.isCertGated && !hasDeviceCert()) { + _uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier) + return } + _uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier) + when (val result = prober.probe(candidate.endpoint)) { + is PairingProbeResult.Success -> onProbeSuccess(candidate) + is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error) + } + } + + /** + * A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a + * `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token + * gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim. + */ + private suspend fun onProbeFailure(candidate: Candidate, error: PairingError) { + if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) { + promptForToken(candidate, error = null) + return + } + _uiState.value = PairingUiState.Failed( + endpoint = candidate.endpoint, + name = candidate.name, + tier = candidate.tier, + error = error, + message = PairingCopy.describe(error, candidate.tier), + ) + } + + private fun promptForToken(candidate: Candidate, error: TokenError?) { + _uiState.value = PairingUiState.TokenRequired( + endpoint = candidate.endpoint, + name = candidate.name, + tier = candidate.tier, + error = error, + ) } private suspend fun onProbeSuccess(candidate: Candidate) { @@ -181,20 +252,80 @@ public class PairingViewModel( 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 { +/** + * The pairing NETWORK seam: the two-step probe ([wang.yaojia.webterm.api.pairing.runPairingProbe]) plus + * the `WEBTERM_TOKEN` gate's two operations. Faked in tests; production is built by + * `AppEnvironment.buildPairingProber()` over the ONE shared `OkHttpClient` (so the token cookie the + * submit obtains is the same jar every later request and the WS upgrade read from). + * + * The two token operations are DEFAULTED so a probe-only double still satisfies the interface. Their + * defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and + * [TokenSubmission.Unavailable] — never a silent success. + */ +public interface PairingProber { public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult + + /** + * Does [endpoint] answer an unauthenticated read with the server's `401 authentication required` + * (`src/server.ts` `authGate`)? Only consulted to disambiguate + * [PairingError.HttpOkButNotWebTerminal]. + * + * Implementations MUST NOT throw for a network failure — an unreachable host is `false` ("not known + * to be gated"), so the app never prompts for a credential because a host is merely down. + */ + public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = false + + /** + * `POST /auth` with [token]. On [TokenSubmission.Accepted] the shared cookie jar holds the host's + * `webterm_auth` cookie, so the very next probe/attach is authenticated. + * + * [token] is a SHELL CREDENTIAL: implementations must put it in the request BODY only — never a URL, + * never a log, never an exception message — and must not retain it. + */ + public suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission = + TokenSubmission.Unavailable } /** - * 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. + * Outcome of `POST /auth` (server `src/http/auth.ts` + the route in `src/server.ts`). A wrong token + * ([Rejected]) and a host that did not answer ([Unreachable]) are DISTINCT cases on purpose — collapsing + * them would tell the user to re-type a token that was fine. */ -public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber = - PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) } +public sealed interface TokenSubmission { + /** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */ + public data object Accepted : TokenSubmission + + /** `401` — the token does not match the host's `WEBTERM_TOKEN`. */ + public data object Rejected : TokenSubmission + + /** `429` — over the host's 10-per-minute auth limit. Surfaced as-is; NEVER auto-retried. */ + public data object RateLimited : TokenSubmission + + /** + * The submit never got an answer it could interpret: a transport failure or an unexpected status. + * [reason] is a short, credential-free diagnostic (an error TYPE or `"HTTP "`) — it is + * shown to nobody and logged nowhere; the UI copy is app-authored. + */ + public data class Unreachable(val reason: String) : TokenSubmission + + /** This prober has no way to submit a token (a probe-only seam). Reported, never silently ignored. */ + public data object Unavailable : TokenSubmission +} + +/** Why the access-token prompt is showing an error. Maps to inert, app-authored copy in [PairingCopy]. */ +public enum class TokenError { + /** The submitted token was rejected by the host (`401`). */ + INVALID, + + /** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */ + RATE_LIMITED, + + /** The host did not answer the submit (or answered unintelligibly). */ + UNREACHABLE, + + /** The app cannot submit a token at all on this seam. */ + UNAVAILABLE, +} // ── Warning tiers (plan §5.4) ─────────────────────────────────────────────────────────────────────── @@ -276,6 +407,27 @@ public sealed interface PairingUiState { */ public data class CertRequired(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState + /** + * The host is behind the optional `WEBTERM_TOKEN` gate and needs its shared access token before + * pairing can continue. [error] is null on the first prompt and set after a failed submit. + * + * This state deliberately carries NO token field — the credential is a + * [PairingViewModel.submitAccessToken] parameter and must not survive the call (plan §8). + */ + public data class TokenRequired( + val endpoint: HostEndpoint, + val name: String, + val tier: WarningTier, + val error: TokenError? = null, + ) : PairingUiState + + /** A submitted access token is in flight (`POST /auth`). Carries no token, for the same reason. */ + public data class TokenSubmitting( + 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, @@ -302,6 +454,8 @@ 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.TokenRequired -> Candidate(endpoint, name, tier) + is PairingUiState.TokenSubmitting -> Candidate(endpoint, name, tier) is PairingUiState.Failed -> Candidate(endpoint, name, tier) is PairingUiState.Entry, is PairingUiState.Paired -> null } @@ -315,7 +469,21 @@ internal object PairingCopy { "这个端口在运行别的服务,不是 web-terminal。端口对吗?" is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived is PairingError.CleartextBlocked -> - "系统阻止了到 ${error.host} 的明文连接。仅局域网 / Tailscale 地址允许 ws:// 明文。" + // Two distinct refusals collapse into this one error case (both surface as + // UnknownServiceException, which is what `PairingError.classify` keys on): + // 1. the PLATFORM's network_security_config, which pins the tunnel apex to TLS because it + // always serves a real LE cert (LAN cleartext itself is permitted — the config format has + // no CIDR syntax, so it could not be scoped to RFC1918); + // 2. the app's OWN transport-level cleartext guard (:transport-okhttp + // CleartextNotPermittedException), which is where the CIDR scoping actually happens: a + // plaintext dial to a PUBLIC (or unclassifiable) host is refused before it leaves the + // device. The tier tells the two apart, so each gets copy the user can act on. + if (tier == WarningTier.TUNNEL) { + "${error.host} 必须使用 https / wss —— 该主机由设备证书保护,不允许降级为明文。" + } else { + "应用拒绝以明文连接 ${error.host}:http:// 与 ws:// 只允许用于本机、局域网和 Tailscale 地址。" + + "公网主机请改用 https:// / wss://(隧道或 Tailscale)。" + } 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"). @@ -324,4 +492,19 @@ internal object PairingCopy { PairingError.Timeout -> "连接超时。主机可能不可达,或响应过慢。" } + + /** + * Copy for the access-token prompt. A wrong token and an unreachable host read differently on + * purpose, and the rate-limit line tells the user to WAIT — the app never retries by itself. + */ + fun describeToken(error: TokenError): String = when (error) { + TokenError.INVALID -> + "访问令牌不正确。请核对主机上 WEBTERM_TOKEN 的值(区分大小写)后重试。" + TokenError.RATE_LIMITED -> + "尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。" + TokenError.UNREACHABLE -> + "提交访问令牌时主机没有正常响应。请检查网络与地址后重试。" + TokenError.UNAVAILABLE -> + "当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。" + } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt index 2ac6b27..8bc94cc 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt @@ -4,9 +4,15 @@ 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.CommitLogEntry +import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.GitLogResult +import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PrStatus import wang.yaojia.webterm.api.models.ProjectDetail +import wang.yaojia.webterm.api.models.SyncState +import wang.yaojia.webterm.api.models.WorktreeInfo +import wang.yaojia.webterm.api.models.WorktreeState import wang.yaojia.webterm.api.routes.ApiClientError /** @@ -30,6 +36,16 @@ public class ProjectDetailViewModel( private val fetchLog: (suspend () -> GitLogResult)? = null, /** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */ public val worktree: WorktreeViewModel? = null, + /** + * w6/G2 — `POST /projects/git/fetch` for THIS repo. `null` ⇒ the band hides the Fetch action + * rather than offering a button that cannot work. + */ + private val fetchRemote: (suspend () -> GitWriteOutcome)? = null, + /** + * w6/G7 — `GET /projects/worktree/state?path=` for ONE worktree. `null` ⇒ rows stay unprobed and + * show no sync chip, which is the honest rendering of "not known". + */ + private val fetchWorktreeState: (suspend (String) -> WorktreeState)? = null, ) { /** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */ public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE } @@ -57,9 +73,23 @@ public class ProjectDetailViewModel( public data object Unavailable : RecentCommits } + /** w6/G2 — the Fetch button's own state. A failure NEVER touches [phase] (failure isolation). */ + public sealed interface FetchPhase { + public data object Idle : FetchPhase + public data object Working : FetchPhase + + /** Succeeded; [lastFetchMs] is the server's post-fetch `FETCH_HEAD` mtime (may be null). */ + public data class Done(val lastFetchMs: Long?) : FetchPhase + + /** Failed (offline / auth / ≥2 remotes). `lastFetchMs` is left untouched so `stale` stays on. */ + public data class Failed(val message: String) : FetchPhase + } + private val _phase = MutableStateFlow(Phase.Loading) private val _prChip = MutableStateFlow(PrChip.Hidden) private val _recentCommits = MutableStateFlow(RecentCommits.Hidden) + private val _fetchPhase = MutableStateFlow(FetchPhase.Idle) + private val _worktreeStates = MutableStateFlow>(emptyMap()) /** The main detail snapshot `ProjectDetailScreen` renders from. */ public val phase: StateFlow = _phase.asStateFlow() @@ -70,6 +100,15 @@ public class ProjectDetailViewModel( /** The recent-commits snapshot. */ public val recentCommits: StateFlow = _recentCommits.asStateFlow() + /** The Fetch action's snapshot (spinner / result banner). */ + public val fetchPhase: StateFlow = _fetchPhase.asStateFlow() + + /** Per-worktree git state, keyed by worktree path. Absent ⇒ unprobed (renders no sync chip). */ + public val worktreeStates: StateFlow> = _worktreeStates.asStateFlow() + + /** Whether a Fetch seam is wired at all; a detached HEAD is refused by [SyncBand.canFetch]. */ + public val canFetch: Boolean get() = fetchRemote != null + /** * Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful * detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail @@ -93,6 +132,72 @@ public class ProjectDetailViewModel( } } + /** + * w6/G2 — refresh the remote-tracking refs for this repo (`git fetch --no-tags`: no pull, no + * merge, no working-tree change). Rules, all pinned by test: + * - **one at a time** — a tap while [FetchPhase.Working] is DROPPED, never queued; + * - **success re-loads the detail**, because `behind`/`lastFetchMs` only move server-side; + * - **failure changes nothing** — no re-load, no invented `lastFetchMs`, so the band's `stale` + * flag stays on. Marking stale data fresh is the one lie this panel exists to prevent. + */ + public suspend fun fetchUpstream() { + val fetcher = fetchRemote ?: return + if (_fetchPhase.value == FetchPhase.Working) return + _fetchPhase.value = FetchPhase.Working + val outcome = try { + fetcher() + } catch (cancel: CancellationException) { + _fetchPhase.value = FetchPhase.Idle + throw cancel + } catch (_: Throwable) { + _fetchPhase.value = FetchPhase.Failed(GitPanelCopy.FETCH_FAILED) + return + } + when (outcome) { + is GitWriteOutcome.Ok -> { + _fetchPhase.value = FetchPhase.Done(outcome.payload.lastFetchMs) + load() // the numbers this button exists for live server-side — re-read them. + } + // The server classifies and sanitizes its own failures (SEC-M10), so its message is shown + // verbatim; a missing one falls back to local copy. Nothing else changes: `lastFetchMs` + // stays where it was, so the band keeps reading "stale" instead of pretending it refreshed. + is GitWriteOutcome.Rejected -> + _fetchPhase.value = FetchPhase.Failed(outcome.message ?: GitPanelCopy.FETCH_FAILED) + GitWriteOutcome.RateLimited -> + _fetchPhase.value = FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED) + } + } + + /** + * w6/G7 — probe the git state of every worktree OTHER than the current one (the current row reads + * the project's own `sync`/`dirtyCount`, already loaded and free). + * + * Each probe is isolated to its own row: a failure leaves that row unprobed (no chip) and never + * touches [phase] or another row. Probed once per opened project, never per refresh tick. + */ + public suspend fun probeWorktrees() { + val probe = fetchWorktreeState ?: return + val detail = (_phase.value as? Phase.Loaded)?.detail ?: return + for (worktree in detail.worktrees) { + if (worktree.isCurrent) continue + val state = try { + probe(worktree.path) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + continue // isolated: this row stays unprobed, the panel still renders + } + _worktreeStates.value = _worktreeStates.value + ( + worktree.path to WorktreeRowState(sync = state.sync, dirtyCount = state.dirtyCount) + ) + } + } + + /** Dismiss the fetch result banner. */ + public fun clearFetchBanner() { + _fetchPhase.value = FetchPhase.Idle + } + private suspend fun loadPr() { val fetcher = fetchPr ?: return _prChip.value = PrChip.Loading @@ -137,9 +242,218 @@ public class ProjectDetailViewModel( fetchPr = { gateway.projectPr(path) }, fetchLog = { gateway.projectLog(path, null) }, worktree = worktree, + fetchRemote = { gateway.gitFetch(path) }, + fetchWorktreeState = { gateway.worktreeState(it) }, ) self = vm return vm } } } + +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// w6 — the git panel's pure presentation core (port of public/projects.ts `makeSyncBand` / +// `makeWorktreeRow` + public/git-log.ts `renderGitLog`; design: docs/mockups/project-detail-git.html). +// Pure and JVM-tested; the Compose layer only paints what these return. +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +/** [GitLogResult.boundaryIndex] when there is no pushed/unpushed boundary to draw. */ +public const val NO_COMMIT_BOUNDARY: Int = -1 + +/** + * The header sync band, as one of three mutually exclusive shapes. + * + * **The rule the type exists to enforce: exactly ONE shape may ever answer [isAllClear] true** — + * [Tracking] with `ahead == 0 && behind == 0` and a fetch inside [SyncState.FETCH_FRESH_WINDOW_MS]. + * Green here means + * "I checked, ignore this", so [Detached] and [NoUpstream] cannot reach it by construction: absent + * numbers are not zeros. + */ +public sealed interface SyncBand { + /** True for the single green "all clear" state and nothing else. */ + public val isAllClear: Boolean + + /** `git fetch` is meaningless without a branch, so a detached HEAD disables the button. */ + public val canFetch: Boolean get() = this !is Detached + + /** HEAD is detached: show the short sha, no branch, no ↑↓. */ + public data class Detached(val head: String?) : SyncBand { + override val isAllClear: Boolean get() = false + } + + /** The branch tracks nothing — stated explicitly, in words as well as colour. */ + public data object NoUpstream : SyncBand { + override val isAllClear: Boolean get() = false + } + + /** Tracking [upstream]: ↑[ahead] is fact, ↓[behind] is only as fresh as [lastFetchMs]. */ + public data class Tracking( + val upstream: String, + val ahead: Int, + val behind: Int, + /** `FETCH_HEAD` older than [SyncState.FETCH_FRESH_WINDOW_MS] (or never) ⇒ [behind] is a guess. */ + val isStale: Boolean, + val lastFetchMs: Long?, + ) : SyncBand { + override val isAllClear: Boolean get() = ahead == 0 && behind == 0 && !isStale + } +} + +/** + * Derive the header band. `null` facts (a non-git project) ⇒ `null` — say nothing rather than guess. + * [head] is the short HEAD sha shown on a detached HEAD. + */ +public fun syncBandOf( + sync: SyncState?, + head: String? = null, + nowMs: Long = System.currentTimeMillis(), +): SyncBand? { + if (sync == null) return null + if (sync.detached) return SyncBand.Detached(head) + // A blank upstream is treated as none: the server should never send one, and "" would otherwise + // label the commit boundary with an empty string and unlock the green path. + val upstream = sync.upstream?.trim()?.takeIf { it.isNotEmpty() } ?: return SyncBand.NoUpstream + return SyncBand.Tracking( + upstream = upstream, + ahead = sync.ahead ?: 0, + behind = sync.behind ?: 0, + isStale = !sync.isFetchFresh(nowMs), + lastFetchMs = sync.lastFetchMs, + ) +} + +/** + * The header's uncommitted-changes chip, or null when clean/unknown. A count is strictly better than + * a bare dot (`● 3 未提交` answers "how much?"), so it wins whenever the server sent one; `dirty` + * alone falls back to the countless chip (mirror of public/projects.ts:1254-1258). + */ +public fun headerDirtyChip(dirtyCount: Int?, dirty: Boolean?): String? = when { + dirtyCount != null && dirtyCount > 0 -> GitPanelCopy.dirtyChip(dirtyCount) + dirtyCount == null && dirty == true -> GitPanelCopy.DIRTY_NO_COUNT + else -> null +} + +// ── Commit list: unpushed marking + ONE upstream boundary ──────────────────────────── + +/** + * The label for the pushed/unpushed boundary, or null when NO boundary may be drawn. + * + * A blank upstream counts as absent: an empty label would be a boundary claiming a position on the + * timeline without naming what it is the position OF. + */ +public fun GitLogResult.boundaryLabel(): String? = upstream?.trim()?.takeIf { it.isNotEmpty() } + +/** + * The row index the boundary is drawn AFTER — exactly once — or [NO_COMMIT_BOUNDARY]. + * + * It is the LAST marked row, not "the first N rows": `git log` is date-ordered and a merge of an older + * branch interleaves unpushed commits BELOW pushed ones, so a row-count rule fails in the dangerous + * direction (calling an unpushed commit pushed). Marking itself is a SERVER decision + * ([GitLogResult.upstreamBoundaryIndex] reads `unpushed`, honoured only when strictly `true`) because + * `git log`'s `%h` is abbreviated while `rev-list` yields full SHAs. + */ +public fun GitLogResult.boundaryIndex(): Int = + if (boundaryLabel() == null) NO_COMMIT_BOUNDARY else upstreamBoundaryIndex ?: NO_COMMIT_BOUNDARY + +/** Whether this row draws the "not pushed yet" rail: only an explicit `true` counts. */ +public fun CommitLogEntry.isUnpushed(): Boolean = unpushed == true + +// ── Worktree rows ──────────────────────────────────────────────────────────────────────────────── + +/** Chip colour role. [OK] is the green all-clear and is reserved for the header band's in-sync chip. */ +public enum class GitChipTone { NEUTRAL, ACCENT, WARN, DIRTY, OK } + +/** One inert chip on a worktree row. */ +public data class WorktreeChip(val label: String, val tone: GitChipTone) + +/** + * Per-row git state (w6/G7 — probed per worktree). Absent means UNPROBED, and an unprobed row shows + * no chip at all rather than a guess. + */ +public data class WorktreeRowState( + val sync: SyncState? = null, + val dirtyCount: Int? = null, + /** Live sessions whose cwd sits inside this worktree. */ + val sessionCount: Int = 0, +) + +/** + * The chips for one worktree row, in the shipped order: main · current · locked · prunable · sync · + * dirty (mirror of public/projects.ts `makeWorktreeRow`). + * + * Deliberately NO green chip here: a worktree row cannot earn the all-clear, because the only state + * that may read green needs a fresh fetch, and "no upstream" — the normal state of a fresh worktree + * branch — must read as a warning instead. + */ +public fun worktreeChips(worktree: WorktreeInfo, state: WorktreeRowState? = null): List { + val chips = ArrayList(6) + if (worktree.isMain) chips += WorktreeChip(GitPanelCopy.MAIN_CHIP, GitChipTone.NEUTRAL) + if (worktree.isCurrent) chips += WorktreeChip(GitPanelCopy.CURRENT_CHIP, GitChipTone.ACCENT) + if (worktree.locked == true) chips += WorktreeChip(GitPanelCopy.LOCKED_CHIP, GitChipTone.NEUTRAL) + if (worktree.prunable == true) chips += WorktreeChip(GitPanelCopy.PRUNABLE_CHIP, GitChipTone.WARN) + val sync = state?.sync + if (sync != null) { + val ahead = sync.ahead ?: 0 + when { + sync.detached -> chips += WorktreeChip(GitPanelCopy.DETACHED_CHIP_SHORT, GitChipTone.WARN) + sync.upstream.isNullOrBlank() -> + chips += WorktreeChip(GitPanelCopy.NO_UPSTREAM_CHIP, GitChipTone.WARN) + ahead > 0 -> chips += WorktreeChip(GitPanelCopy.aheadChip(ahead), GitChipTone.ACCENT) + } + } + val dirtyCount = state?.dirtyCount ?: 0 + if (dirtyCount > 0) chips += WorktreeChip(GitPanelCopy.dirtyCountChip(dirtyCount), GitChipTone.DIRTY) + return chips +} + +/** User-visible copy for the git panel. Local UI text — no wire contract depends on these strings. */ +public object GitPanelCopy { + // Sync band + public const val UPSTREAM_CAPTION: String = "Upstream" + public const val TO_PUSH_CAPTION: String = "待推送" + public const val TO_PULL_CAPTION: String = "待拉取" + public const val HEAD_CAPTION: String = "HEAD" + public const val NO_UPSTREAM_CHIP: String = "无 upstream" + public const val NO_UPSTREAM_NOTE: String = "该分支没有跟踪上游 —— 没有可报告的 ↑↓" + public const val DETACHED_CHIP: String = "detached HEAD" + public const val DETACHED_CHIP_SHORT: String = "detached" + public const val IN_SYNC_CHIP: String = "✓ 已同步" + public const val STALE_CHIP: String = "存疑" + public const val NEVER_FETCHED_NOTE: String = "从未 fetch —— 这个数字不可信" + public const val STALE_NOTE: String = "上次 fetch 已超过一小时 —— 这个数字不可信" + public const val UP_TO_DATE_NOTE: String = "与远端一致" + public const val WAITING_ON_REMOTE_NOTE: String = "远端有新提交" + public const val NOTHING_TO_PUSH_NOTE: String = "没有待推送的提交" + public const val DIRTY_NO_COUNT: String = "● 未提交" + + // Fetch + public const val FETCH: String = "Fetch" + public const val FETCH_WORKING: String = "正在 fetch…" + public const val FETCH_DONE: String = "已刷新远端引用(未 pull、未 merge)" + public const val FETCH_FAILED: String = "fetch 失败,远端状态仍是上次的数据。" + public const val FETCH_RATE_LIMITED: String = "fetch 太频繁,请稍后再试。" + public const val FETCH_DISABLED_DETACHED: String = "HEAD 游离,无法 fetch" + + // Commit list + public const val RECENT_COMMITS: String = "最近提交" + public const val UNPUSHED_MARK: String = "↑" + + // Worktrees + public const val MAIN_CHIP: String = "main" + public const val CURRENT_CHIP: String = "当前" + public const val LOCKED_CHIP: String = "locked" + public const val PRUNABLE_CHIP: String = "prunable" + + /** ALWAYS "Worktrees (n)" — one worktree per session means the count is the point, and a section + * that renames itself at n=1 hides the whole model (w6/G5). */ + public fun worktreesTitle(count: Int): String = "Worktrees ($count)" + + public fun dirtyChip(count: Int): String = "● $count 未提交" + public fun dirtyCountChip(count: Int): String = "● $count" + public fun aheadChip(count: Int): String = "↑ $count" + public fun behindChip(count: Int): String = "↓ $count" + public fun unpushedBadge(count: Int): String = "↑ $count 未推送" + public fun aheadNote(count: Int): String = "$count 个提交只在本机" + public fun sessionCount(count: Int): String = "● $count session" + public fun truncatedNote(count: Int): String = "只显示最近 $count 条提交。" +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt index ef6105e..2bc5fef 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import wang.yaojia.webterm.api.models.CreateWorktreeResult +import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.GitLogResult import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PrStatus @@ -14,6 +15,7 @@ import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.PruneWorktreesResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult import wang.yaojia.webterm.api.models.UiPrefs +import wang.yaojia.webterm.api.models.WorktreeState import wang.yaojia.webterm.api.routes.ApiClient import wang.yaojia.webterm.api.routes.ApiClientError import wang.yaojia.webterm.wire.Validation @@ -418,6 +420,18 @@ public interface ProjectsGateway { public suspend fun projectPr(path: String): PrStatus public suspend fun projectLog(path: String, n: Int? = null): GitLogResult + /** + * w6/G7 — one worktree's git state. Its own call (not part of `projectDetail`) so N rows do not + * each pay for a worktree listing and a CLAUDE.md read. + */ + public suspend fun worktreeState(worktreePath: String): WorktreeState + + /** + * w6/G2 — refresh remote-tracking refs (no pull, no merge). GUARDED like the other git writes: + * the server derives the remote itself and never accepts one from the client. + */ + public suspend fun gitFetch(path: String): GitWriteOutcome + // ── W5: guarded worktree write ───────────────────────────────────────────────────────── public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome @@ -432,6 +446,8 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path) override suspend fun projectPr(path: String): PrStatus = api.projectPr(path) override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n) + override suspend fun worktreeState(worktreePath: String): WorktreeState = api.worktreeState(worktreePath) + override suspend fun gitFetch(path: String): GitWriteOutcome = api.gitFetch(path) override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome = api.createWorktree(path, branch, base) diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/QueueViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/QueueViewModel.kt new file mode 100644 index 0000000..519a1ff --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/QueueViewModel.kt @@ -0,0 +1,269 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import wang.yaojia.webterm.api.models.QueueSnapshot +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.api.routes.ApiClient +import wang.yaojia.webterm.api.routes.ApiClientError +import java.util.UUID + +/** + * # QueueViewModel (W2) — the follow-up prompt queue presenter. + * + * Drives the three server routes (`POST` / `GET` / `DELETE /live-sessions/:id/queue`) behind the + * [QueueGateway] seam: queue a prompt Claude will be handed the next time it goes idle, review what is + * pending, and cancel everything. Ports the web client's `public/queue.ts` affordance. + * + * ### The queued text is raw shell input (invariant #9 / byte-shuttle) + * [enqueue] passes [text] to the gateway **verbatim** — no trim, no normalisation, no case folding, and + * **never a client-side `\r`**. `appendEnter` stays a FLAG so the SERVER materialises the carriage + * return, byte-identical to a keystroke. The only refusal is a genuinely EMPTY string (mirroring + * [ApiClientError.QueueTextEmpty], raised before any I/O); a whitespace-only prompt is legitimate shell + * input and is sent as-is. + * + * ### The depth is always the server's number + * [QueueUiState.depth] is only ever assigned from an authoritative source — a write's + * [QueueWriteOutcome.Ok.length], a [refresh] snapshot, or the live `queue` WS frame ([onServerDepth]). + * Nothing here increments a local counter, so the badge can never drift from the FIFO the server holds. + * + * ### Failure honesty (no auto-retry, ever) + * Each [QueueWriteOutcome] maps to its own [QueueNotice] and the pass STOPS there: + * - [QueueNotice.RateLimited] (429, the shared 20/min/IP bucket) is **shown, never retried** — a retry + * loop only burns the budget a real enqueue needs; + * - [QueueNotice.Disabled] (503, `QUEUE_ENABLED=0`) additionally clears [QueueUiState.isSupported], so + * the affordance disappears for this host instead of failing over and over; + * - [QueueNotice.Full] / [QueueNotice.TooLarge] / [QueueNotice.SessionGone] are actionable states, and + * [QueueNotice.Rejected] carries the server's own already-sanitized `error` string, rendered INERT. + * A transport throw becomes [QueueNotice.Unreachable] and the last-good queue is kept — a network blip + * must not make the panel claim the queue is empty. + * + * ### Testability + * A plain presenter (not an `androidx.lifecycle.ViewModel`) so every branch runs under `runTest` with no + * `Dispatchers.Main`/Robolectric. Its Compose surface ([wang.yaojia.webterm.components.QueuePanel]) is + * device-QA (plan §7). + */ +public class QueueViewModel(private val gateway: QueueGateway) { + + private val _uiState = MutableStateFlow(QueueUiState()) + + /** The single snapshot the queue panel + depth badge render from. */ + public val uiState: StateFlow = _uiState.asStateFlow() + + /** + * `GET /live-sessions/:id/queue` — the pending depth plus the queued prompts (verbatim), for the + * "review before cancelling" panel. Read-only and safe to call on every panel open. + */ + public suspend fun refresh(sessionId: UUID) { + _uiState.update { it.copy(isBusy = true) } + val snapshot = try { + gateway.snapshot(sessionId) + } catch (cancel: CancellationException) { + _uiState.update { it.copy(isBusy = false) } + throw cancel + } catch (error: Throwable) { + _uiState.update { it.copy(isBusy = false, notice = noticeFor(error)) } + return + } + publish(snapshot) + } + + /** + * `POST /live-sessions/:id/queue` — queue [text] VERBATIM. An empty [text] is refused locally (no + * network I/O), matching the server's own 400 and [ApiClientError.QueueTextEmpty]; the size cap is + * deliberately NOT pre-checked here (it is server config the client cannot know) and surfaces as + * [QueueNotice.TooLarge]. + * + * @param appendEnter leave `true` so the SERVER appends the `\r`; never pre-append one. + */ + public suspend fun enqueue(sessionId: UUID, text: String, appendEnter: Boolean = true) { + if (!canSend(text)) return + write { gateway.enqueue(sessionId, text, appendEnter) } + } + + /** + * `DELETE /live-sessions/:id/queue` — cancel EVERY pending entry. There is no per-item cancel on the + * server, so the panel must not pretend to offer one. + */ + public suspend fun clearAll(sessionId: UUID) { + write { gateway.clear(sessionId) } + } + + /** + * The live `queue` WS frame ([wang.yaojia.webterm.session.Queued]) — the server's authoritative + * depth. A positive depth that no longer matches the cached [QueueUiState.items] marks them STALE + * (the panel then re-reads rather than showing a list that contradicts the badge); a zero depth is + * unambiguous and empties the list outright. + */ + public fun onServerDepth(length: Int) { + val depth = length.coerceAtLeast(0) + _uiState.update { state -> + if (depth == 0) { + state.copy(depth = 0, items = emptyList(), isItemsStale = false) + } else { + state.copy(depth = depth, isItemsStale = depth != state.items.size) + } + } + } + + /** Dismiss the current [QueueNotice]; the queue contents and depth are untouched. */ + public fun dismissNotice() { + _uiState.update { it.copy(notice = null) } + } + + // ── Internals ──────────────────────────────────────────────────────────────────────────────── + + /** Shared body of the two guarded writes: busy flag → one attempt → typed outcome → NO retry. */ + private suspend fun write(attempt: suspend () -> QueueWriteOutcome) { + _uiState.update { it.copy(isBusy = true, notice = null) } + val outcome = try { + attempt() + } catch (cancel: CancellationException) { + _uiState.update { it.copy(isBusy = false) } + throw cancel + } catch (error: Throwable) { + _uiState.update { it.copy(isBusy = false, notice = noticeFor(error)) } + return + } + apply(outcome) + } + + /** + * Fold one write outcome into the state. On success the depth is the server's number and the cached + * item list is re-read lazily (marked stale unless the queue is now empty). + */ + private fun apply(outcome: QueueWriteOutcome) { + when (outcome) { + is QueueWriteOutcome.Ok -> { + onServerDepth(outcome.length) + _uiState.update { it.copy(isBusy = false, notice = null) } + } + + QueueWriteOutcome.Full -> fail(QueueNotice.Full) + QueueWriteOutcome.TooLarge -> fail(QueueNotice.TooLarge) + QueueWriteOutcome.SessionGone -> fail(QueueNotice.SessionGone) + QueueWriteOutcome.RateLimited -> fail(QueueNotice.RateLimited) + QueueWriteOutcome.Disabled -> { + // A capability answer, not a transient failure: stop offering the affordance on this host. + _uiState.update { it.copy(isBusy = false, isSupported = false, notice = QueueNotice.Disabled) } + } + + is QueueWriteOutcome.Rejected -> fail(QueueNotice.Rejected(outcome.message)) + } + } + + private fun fail(notice: QueueNotice) { + _uiState.update { it.copy(isBusy = false, notice = notice) } + } + + private fun publish(snapshot: QueueSnapshot) { + _uiState.update { + it.copy( + depth = snapshot.length.coerceAtLeast(0), + items = snapshot.items, + isBusy = false, + isItemsStale = false, + notice = null, + ) + } + } + + /** A thrown failure → its notice. A 404 is a real state; everything else is "could not reach". */ + private fun noticeFor(error: Throwable): QueueNotice = when (error) { + is ApiClientError.SessionNotFound -> QueueNotice.SessionGone + else -> QueueNotice.Unreachable + } + + public companion object { + /** + * May [text] be sent? Only EMPTY is refused — whitespace is legitimate keyboard input for a + * shell, so trimming here would silently change what the user typed (invariant #9). + */ + public fun canSend(text: String): Boolean = text.isNotEmpty() + } +} + +/** The queue panel + badge snapshot. [items] is empty when nothing is queued OR nothing was read yet. */ +public data class QueueUiState( + /** The server's pending depth — the "N queued" badge number. Never locally incremented. */ + val depth: Int = 0, + /** The queued prompts in FIFO order, VERBATIM (rendered inert — untrusted round-tripped text). */ + val items: List = emptyList(), + /** A write/read is in flight (the send + cancel buttons disable). */ + val isBusy: Boolean = false, + /** False after a 503 — the host has `QUEUE_ENABLED=0`, so hide the affordance entirely. */ + val isSupported: Boolean = true, + /** [items] no longer matches [depth] (a live frame moved the queue) — re-read before trusting it. */ + val isItemsStale: Boolean = false, + /** The last failure to report, or null. */ + val notice: QueueNotice? = null, +) + +/** + * What went wrong on the last queue operation. Typed (not a status code) because each one demands + * different UI behaviour; [Rejected] carries the server's own sanitized `error` string, which must be + * rendered INERT (plain text, no linkify/markdown — plan §8). + */ +public sealed interface QueueNotice { + /** 409 — the bounded FIFO is at `QUEUE_MAX_ITEMS`; cancel something first. */ + public data object Full : QueueNotice + + /** 413 — over the server's per-item byte cap. */ + public data object TooLarge : QueueNotice + + /** 503 — the queue is switched off on this host. */ + public data object Disabled : QueueNotice + + /** 404 — the session exited or was killed. */ + public data object SessionGone : QueueNotice + + /** 429 — the shared 20/min/IP bucket. Shown and NEVER auto-retried. */ + public data object RateLimited : QueueNotice + + /** A transport failure / unparseable body — the queue state is unknown, the last-good view is kept. */ + public data object Unreachable : QueueNotice + + /** Any other 4xx/5xx, with the server's inert message (null when the body was empty). */ + public data class Rejected(val message: String?) : QueueNotice +} + +/** + * The three queue routes as a seam, so [QueueViewModel] is JVM-testable with no transport. Production is + * [ApiClientQueueGateway] over the per-host [ApiClient]; tests script outcomes. + */ +public interface QueueGateway { + /** `GET /live-sessions/:id/queue` — depth + the queued prompts. Throws for 404 / a garbled body. */ + public suspend fun snapshot(id: UUID): QueueSnapshot + + /** `POST /live-sessions/:id/queue` — [text] passed through VERBATIM. */ + public suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome + + /** `DELETE /live-sessions/:id/queue` — cancel-all (there is no per-item cancel). */ + public suspend fun clear(id: UUID): QueueWriteOutcome +} + +/** + * Production [QueueGateway] over one host's [ApiClient] (which stamps `Origin` on the two writes). + * + * [api] is a SUPPLIER and every call hops to [ioDispatcher], for the reason `ApiClientFactory` documents: + * resolving the shared `HttpTransport` builds the mTLS `OkHttpClient` (AndroidKeyStore + Tink reads), and + * this gateway is driven from a Composable's `rememberCoroutineScope()` — i.e. from `Main`. Building the + * gateway therefore stays free, and no keystore work can land on the UI thread. + */ +public class ApiClientQueueGateway( + private val api: () -> ApiClient, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : QueueGateway { + override suspend fun snapshot(id: UUID): QueueSnapshot = withContext(ioDispatcher) { api().queue(id) } + + override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome = + withContext(ioDispatcher) { api().enqueueFollowup(id, text, appendEnter) } + + override suspend fun clear(id: UUID): QueueWriteOutcome = withContext(ioDispatcher) { api().clearQueue(id) } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt index 10f741f..92036d2 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt @@ -14,6 +14,7 @@ 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.hostregistry.SessionWatermarkStore import wang.yaojia.webterm.session.TitleSanitizer import wang.yaojia.webterm.session.UnreadLedger import wang.yaojia.webterm.wire.Tunables @@ -25,9 +26,16 @@ import kotlin.time.Duration * * 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. + * title · unread dot · W2 queue depth), and owns the list actions: STARTED-scoped [poll], [refresh] + * (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open), [killSession] + * (optimistic swipe-to-kill) and [removeActiveHost] (the confirm-gated un-pair). Ports the iOS + * session-list surface. + * + * ### The unread dot is DURABLE + * Watermarks live behind [UnreadWatermarkStore]; production binds [PersistedUnreadWatermarkStore] over + * `:host-registry`'s DataStore store, so the dot survives process death — exactly when it matters, the + * product premise being "walk away and come back". The reducer stays in `UnreadLedger` (`:session-core`): + * this VM only decides WHERE a watermark lives, never how one is computed. * * ### Untrusted server boundary (plan §4 / §8) * The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` → @@ -45,14 +53,16 @@ import kotlin.time.Duration * ### 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). + * [UnreadWatermarkStore], [HostRemovalGateway]) so the fetch→row mapping, unread-dot logic, + * optimistic-kill path and the all-or-nothing un-pair 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(), + watermarkStore: UnreadWatermarkStore? = null, private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL, + removalGateway: HostRemovalGateway? = null, ) { private val _uiState = MutableStateFlow(SessionListUiState()) @@ -68,10 +78,59 @@ public class SessionListViewModel( /** 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. */ + /** Local unread watermarks (persisted through [watermarks]); loaded once, lazily. */ private var ledger: UnreadLedger = UnreadLedger() private var ledgerLoaded = false + /** + * Where the watermarks live. Defaults to memory-only so a test/preview needs no storage; production + * installs the DataStore-backed [PersistedUnreadWatermarkStore] via [useWatermarkStore]. + */ + private var watermarks: UnreadWatermarkStore = watermarkStore ?: InMemoryUnreadWatermarkStore() + + /** True when the store came from the constructor — [useWatermarkStore] must never override it. */ + private var watermarkStoreInstalled: Boolean = watermarkStore != null + + /** Un-pairing collaborator, installable post-construction for the same reason as [watermarks]. */ + private var removal: HostRemovalGateway? = removalGateway + private var removalInstalled: Boolean = removalGateway != null + + // ── Why the two collaborators above are installable AFTER construction ─────────────────────── + // Both are app-scoped Hilt singletons, but this VM is constructed by the nav layer, which does not + // reach the Hilt graph; the SCREEN does (`SessionListScreen` resolves them through a Hilt + // `@EntryPoint` — the same escape hatch `TerminalScreen` already uses for its palette store). + // Constructor injection remains the preferred wiring and always WINS: passing either one makes the + // matching installer a no-op, so a test (or a future nav-side wiring) is never overridden. + + /** + * Install the DURABLE watermark store, ONCE, before the ledger is first read. + * + * Refused (returning false, changing nothing) when a store is already installed OR the ledger has + * already been loaded — swapping stores mid-flight could resurrect watermarks the live ledger already + * dropped, or silently discard a `markSeen` that was persisted elsewhere. + * + * @return true when [store] became this VM's watermark home. + */ + public fun useWatermarkStore(store: UnreadWatermarkStore): Boolean { + if (watermarkStoreInstalled || ledgerLoaded) return false + watermarks = store + watermarkStoreInstalled = true + return true + } + + /** + * Install the host-removal collaborator, ONCE. Without it [removeActiveHost] is a no-op (which is the + * correct behaviour for a preview/test that has no way to un-pair anything). + * + * @return true when [gateway] became this VM's removal path. + */ + public fun useRemovalGateway(gateway: HostRemovalGateway): Boolean { + if (removalInstalled) return false + removal = gateway + removalInstalled = true + return true + } + /** * 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)`. @@ -107,7 +166,7 @@ public class SessionListViewModel( 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) } + runCatching { watermarks.save(ledger) } _uiState.update { it.copy(rows = it.rows.map { r -> r.withUnread(ledger) }) } } @@ -134,6 +193,46 @@ public class SessionListViewModel( } } + /** + * Un-pair the ACTIVE host: hand it (and the session ids currently listed for it) to the + * [HostRemovalGateway], which is the ONE place that erases every trace keyed off that host — + * the push registration on the host itself (`DELETE /push/fcm-token`, otherwise the phone keeps + * receiving notifications for a host the user dropped), the paired record, the last-session pointer, + * the `webterm_auth` cookie, and those sessions' unread watermarks. + * + * Only the ACTIVE host is removable, because the live session ids — and therefore the watermarks to + * drop — are only known for the host currently listed. Switching first is one tap. + * + * ALL-OR-NOTHING at the UI level: a failure leaves the host exactly where it was and raises + * [SessionListUiState.hasRemoveError], because a half-removed host is worse than none. On success the + * list re-fetches, so the active host re-resolves to a survivor (or the empty pair-a-host state). + */ + public suspend fun removeActiveHost() { + val gateway = removal ?: return + val hostId = activeHostId ?: return + if (!hostsById.containsKey(hostId)) return + val sessionIds = _uiState.value.rows.map { it.id.toString() } + + try { + gateway.removeHost(hostId, sessionIds) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + _uiState.update { it.copy(hasRemoveError = true) } + return + } + // Forget the pointer so the next fetch resolves the first SURVIVING host instead of re-selecting + // a host that no longer exists. + activeHostId = null + _uiState.update { it.copy(hasRemoveError = false) } + fetch(markRefreshing = true) + } + + /** Dismiss the removal-failure banner (the host is still paired; nothing else changed). */ + public fun dismissRemoveError() { + _uiState.update { it.copy(hasRemoveError = false) } + } + // ── Internals ──────────────────────────────────────────────────────────────────────────────── private suspend fun fetch(markRefreshing: Boolean) { @@ -202,7 +301,7 @@ public class SessionListViewModel( private suspend fun ensureLedgerLoaded() { if (ledgerLoaded) return - ledger = runCatching { watermarkStore.load() }.getOrDefault(UnreadLedger()) + ledger = runCatching { watermarks.load() }.getOrDefault(UnreadLedger()) ledgerLoaded = true } } @@ -234,6 +333,11 @@ public data class SessionListUiState( val isRefreshing: Boolean = false, /** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */ val hasLoadError: Boolean = false, + /** + * The last host removal failed, so the host is STILL paired (all-or-nothing). The screen shows an + * inline notice; nothing was partially erased. + */ + val hasRemoveError: Boolean = false, ) /** @@ -299,7 +403,7 @@ public interface UnreadWatermarkStore { public suspend fun save(ledger: UnreadLedger) } -/** In-memory [UnreadWatermarkStore] (default). Watermarks live for the process only until DataStore lands. */ +/** In-memory [UnreadWatermarkStore] — the test/preview double. Watermarks live for the process only. */ public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()) : UnreadWatermarkStore { @Volatile private var current: UnreadLedger = initial override suspend fun load(): UnreadLedger = current @@ -307,3 +411,46 @@ public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger() current = ledger } } + +/** + * The PRODUCTION [UnreadWatermarkStore]: the ledger-typed seam bridged onto `:host-registry`'s + * DataStore-backed [SessionWatermarkStore], so the unread dot survives process death (the whole point — + * the product premise is walking away and coming back). + * + * The two halves are deliberately typed differently and this adapter is the ONLY join: + * - [UnreadLedger] (`:session-core`) owns the *reducer* — the monotonic `record`, the `isUnread` + * comparison, the tie-broken cap; + * - [SessionWatermarkStore] (`:host-registry`) owns *storage* — validation of ids at rest, the growth + * cap, the JSON blob. + * `:host-registry` may not depend on `:session-core` (nothing points sideways), so neither restates the + * other; `:app` is the one module that sees both. Nothing about the reducer is reimplemented here. + * + * The store applies its own sanitize+cap pass, so [load] can legitimately return FEWER watermarks than + * [save] was given (an invalid id at rest is dropped). That is the correct behaviour, not a bug: the + * ledger simply treats a dropped session as never-seen. + */ +public class PersistedUnreadWatermarkStore( + private val store: SessionWatermarkStore, +) : UnreadWatermarkStore { + override suspend fun load(): UnreadLedger = UnreadLedger(store.loadAll()) + + override suspend fun save(ledger: UnreadLedger) { + store.replaceAll(ledger.watermarks) + } +} + +/** + * Un-pairing a host, as ONE operation (plan §8 — a half-removed host is worse than none). + * + * A single seam rather than five calls from the ViewModel, because the steps span four modules + * (`:api-client` push DELETE, `:host-registry` host/last-session/cookie/watermark stores, + * `:transport-okhttp`'s live cookie jar) and the VM must stay JVM-testable with no transport. + * Production is `HostRemover` in the composition root. + * + * @param sessionIds the host's currently-known live session ids, so their unread watermarks are dropped + * too (watermarks are keyed by SESSION, not host — see `HostRemover`). + */ +public fun interface HostRemovalGateway { + /** Erase every trace of [hostId]. Throws if anything essential failed (the UI then keeps the host). */ + public suspend fun removeHost(hostId: String, sessionIds: List) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt index 7ef05a7..e1fdbd9 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt @@ -1,15 +1,44 @@ package wang.yaojia.webterm.wiring +import android.util.Log import dagger.Lazy +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import wang.yaojia.webterm.api.models.UiConfig +import wang.yaojia.webterm.api.pairing.PairingProbeResult import wang.yaojia.webterm.api.pairing.runPairingProbe +import wang.yaojia.webterm.hostregistry.AuthCookieRecord +import wang.yaojia.webterm.hostregistry.AuthCookieStore +import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.LastSessionStore +import wang.yaojia.webterm.hostregistry.SessionWatermarkStore import wang.yaojia.webterm.tlsandroid.IdentityRepository +import wang.yaojia.webterm.transport.AuthCookieJar +import wang.yaojia.webterm.transport.AuthCookieSnapshot +import wang.yaojia.webterm.transport.authCookieHostKey +import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway +import wang.yaojia.webterm.viewmodels.HostRemovalGateway import wang.yaojia.webterm.viewmodels.PairingProber +import wang.yaojia.webterm.viewmodels.PersistedUnreadWatermarkStore +import wang.yaojia.webterm.viewmodels.QueueGateway +import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore +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 wang.yaojia.webterm.wire.TermTransport +import java.net.URI +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton @@ -28,10 +57,11 @@ import javax.inject.Singleton * 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. + * The mTLS transports, [identityRepository], the auth-cookie store and the thumbnail rasterizer are all + * behind `dagger.Lazy` so constructing this root does NO AndroidKeyStore/Tink/`OkHttpClient`/DataStore/ + * `android.graphics` work on the injection (often `Main`) thread — `MainActivity` field-injects it in + * `onCreate`. [warmUp] resolves them on `Dispatchers.IO`, so the shared client is built, the device + * identity first-touched and the persisted session cookie rehydrated off the UI thread. * * What lives here (all app singletons): * - [hostStore] / [lastSessionStore] — persisted, non-secret UI state (DataStore). @@ -39,6 +69,11 @@ import javax.inject.Singleton * - [apiClientFactory] / [sessionEngineFactory] — per-host / per-session builders over the shared * transports (lazy). * - [coldStartPolicy] — the cold-launch route seam (A29). + * - the `webterm_auth` cookie jar + its durable store — the optional `WEBTERM_TOKEN` gate. + * - [unreadWatermarkStore] — the DURABLE unread-dot watermarks (was memory-only; reset on process death). + * - [uiConfigGate] — the host's `allowAutoMode` policy, fail-closed (`GET /config/ui`). + * - [hostRemoval] — un-pair a host and erase every trace keyed off it, push registration included. + * - [runCertificateRotationIfDue] / [rotationOutcomes] — silent device-certificate rotation. * Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its * [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder]. */ @@ -57,7 +92,37 @@ public class AppEnvironment @Inject constructor( private val identityRepositoryLazy: Lazy, private val httpTransportLazy: Lazy, private val termTransportLazy: Lazy, + /** The SAME jar instance installed on the one shared `OkHttpClient` (`di/AuthCookieModule`). */ + private val authCookieJarLazy: Lazy, + /** Durable, Tink-AEAD-encrypted home of the `webterm_auth` cookie (`di/AuthCookieModule`). */ + private val authCookieStoreLazy: Lazy, + /** + * The off-screen preview painter (§6.7). `Lazy` because constructing it measures a `Paint` — an + * `android.graphics` touch that must not happen while the Hilt graph is being built (it is stubbed + * under JVM unit tests). + */ + private val thumbnailRasterizerLazy: Lazy, + /** + * The durable, DataStore-backed home of the unread watermarks (`di/StorageModule`). Exposed to the + * session list through [unreadWatermarkStore]; without it the unread dot resets on process death. + */ + private val sessionWatermarkStore: SessionWatermarkStore, + /** + * Silent device-certificate rotation (`di/TlsModule`). `Lazy` for the same reason as the transports: + * resolving it must not pull AndroidKeyStore/Tink work onto the injection (often `Main`) thread. + */ + private val rotationSchedulerLazy: Lazy, + /** Per-host push registration — the `DELETE /push/fcm-token` half is what [removeHost] finally calls. */ + private val pushUnregister: HostPushUnregister, + /** Resolves this device's current FCM token, or null when push is unavailable/uninitialised. */ + private val pushTokenSource: PushTokenSource, ) { + /** One-shot cold-start restore of the persisted session cookie into the live jar (see [warmUp]). */ + private val authCookieHydrator = AuthCookieHydrator( + store = { authCookieStoreLazy.get() }, + jar = { authCookieJarLazy.get() }, + ) + /** * 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 @@ -74,26 +139,520 @@ public class AppEnvironment @Inject constructor( 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]. + * Build the pairing network seam (A19) over the ONE shared transports: the two-step probe plus the + * `WEBTERM_TOKEN` gate's detect + submit. The transports are resolved LAZILY inside each call (not at + * build time) so constructing the prober on the UI thread never triggers the mTLS/OkHttp build — the + * actual resolve happens in the caller's own coroutine, after [warmUp]. + * + * The submit rides the SAME client as everything else, which is the whole point: the `webterm_auth` + * cookie it earns lands in the shared [AuthCookieJar] and is therefore replayed on every later REST + * call AND on the WS upgrade. */ - public fun buildPairingProber(): PairingProber = - PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) } + public fun buildPairingProber(): PairingProber = object : PairingProber { + private val gateway = AccessTokenGateway { httpTransportLazy.get() } + + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult = + runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) + + override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = + gateway.requiresAccessToken(endpoint) + + override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission = + gateway.submit(endpoint, token) + } /** - * 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. + * Build the session-list / manage-page thumbnail pipeline for one host (§6.7). Per-host because its + * preview fetch is bound to that host's [ApiClient][wang.yaojia.webterm.api.routes.ApiClient] + * (`Origin` is stamped per endpoint), so it cannot be an app singleton. + * + * The CALLER owns the returned pipeline's lifetime: keep it in a `remember(env, hostId)` and call + * `close()` from a `DisposableEffect` so its in-flight renders are cancelled with the screen. + * Resolving the rasterizer measures a `Paint` — cheap, but `android.graphics`, so call this from the + * composition, never from DI construction. + */ + public fun buildThumbnailPipeline(endpoint: HostEndpoint): ThumbnailPipeline = + ThumbnailPipeline( + previewSource = ApiPreviewSource { apiClientFactory.create(endpoint) }, + rasterizer = thumbnailRasterizerLazy.get(), + ) + + // ── Unread watermarks (durable) ─────────────────────────────────────────────────────────────── + + /** + * The PRODUCTION unread-watermark store: DataStore-backed, so the session-list dot survives process + * death. The session list installs it via `SessionListViewModel.useWatermarkStore`. + */ + public val unreadWatermarkStore: UnreadWatermarkStore = + PersistedUnreadWatermarkStore(sessionWatermarkStore) + + // ── W2 prompt queue ────────────────────────────────────────────────────────────────────────── + + /** + * The follow-up-queue seam for one host (`POST|GET|DELETE /live-sessions/:id/queue`). Per-host + * because the two writes are `Origin`-guarded and the header is derived from the endpoint. + * + * Cheap + `Main`-safe to build: the `ApiClient` is minted by a SUPPLIER inside the gateway's own + * `Dispatchers.IO` hop, so resolving the shared mTLS `OkHttpClient` never happens on the UI thread + * (the same discipline `sessionThumbnailPipeline` follows). + */ + public fun buildQueueGateway(endpoint: HostEndpoint): QueueGateway = + ApiClientQueueGateway(api = { apiClientFactory.create(endpoint) }) + + // ── GET /config/ui (allowAutoMode) ─────────────────────────────────────────────────────────── + + /** + * The host-policy gate for `approve.mode="acceptEdits"`. One instance for the app so a host is asked + * once per process; it FAILS CLOSED, so an unreachable host never widens what the phone may approve. + */ + public val uiConfigGate: UiConfigGate = UiConfigGate { endpoint -> + apiClientFactory.create(endpoint).uiConfig() + } + + // ── Host removal ───────────────────────────────────────────────────────────────────────────── + + /** + * Un-pair a host and erase EVERY trace keyed off it — including the `DELETE /push/fcm-token` that had + * no caller at all, which is why a removed host kept pushing notifications to this device. See + * [HostRemover] for the ordering rules. + */ + public val hostRemoval: HostRemovalGateway by lazy { + // `by lazy` because the session-list screen reads this on `Main`. Resolving the auth-cookie + // store here is safe by that module's documented contract — `TinkAuthCookieCipher` defers ALL + // AndroidKeyStore/AEAD work to first use — and in practice `warmUp()` has already resolved it + // off-`Main`. Nothing in this block does I/O; the removal itself runs in the caller's coroutine. + HostRemover( + hostStore = hostStore, + lastSessionStore = lastSessionStore, + authCookieStore = authCookieStoreLazy.get(), + watermarkStore = sessionWatermarkStore, + pushUnregister = pushUnregister, + currentPushToken = { pushTokenSource.currentToken() }, + clearLiveCookies = { hostKey -> authCookieJarLazy.get().clear(hostKey) }, + ) + } + + // ── Silent certificate rotation ────────────────────────────────────────────────────────────── + + /** + * App-lifetime scope for the fire-and-forget rotation pass. Deliberately NOT the caller's scope: + * [warmUp] is awaited by the terminal screen's readiness gate, and a renewal's network round-trip + * must never be able to hold the UI behind a spinner. + */ + private val rotationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val rotationTrigger = CertificateRotationTrigger( + scope = rotationScope, + scheduler = { rotationSchedulerLazy.get() }, + onError = { error -> + // Shape only: a rotation error can carry a control-plane URL or response body. + Log.w(ROTATION_TAG, "certificate rotation could not start (${error::class.simpleName})") + }, + ) + + /** + * Run one silent certificate-rotation pass if one is due (the Android counterpart of iOS + * `AppCoordinator.runCertificateRotationIfDue()`). Safe on launch AND on every foreground: the + * scheduler is idempotent, self-coalescing, and `NotDue` costs one store read. Returns immediately. + */ + public fun runCertificateRotationIfDue() { + rotationTrigger.fire() + } + + /** + * The most recent rotation pass's outcome (null before the first pass). Observed by the device-cert + * screen so a silently failing renewal becomes visible instead of only a log line. + */ + public fun rotationOutcomes(): StateFlow = rotationSchedulerLazy.get().lastOutcome + + /** + * Build the shared `OkHttpClient`, rehydrate the persisted session cookie 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; the cookie + * hydration reads DataStore + Tink. Idempotent at the singleton level — the client/identity are built + * once and cached, and the hydration is one-shot ([AuthCookieHydrator]). Safe to call from any + * coroutine; hops to `Dispatchers.IO` internally. */ public suspend fun warmUp() { withContext(Dispatchers.IO) { + // First: put the stored credential in the jar, so the very first dial is already authenticated + // on a token-gated host (the jar is consulted per request, so this races nothing). + authCookieHydrator.hydrateOnce() 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 } + // Rotate the device certificate if it is due. Fired LAST and fire-and-forget: it needs the warmed + // mTLS stack, and its network round-trip must not be awaited by this function's callers (the + // terminal screen blocks its readiness gate on warmUp). The AndroidKeyStore/Tink reads happen on + // the scheduler's own IO dispatcher, never on `Main`. + runCertificateRotationIfDue() + } + + private companion object { + const val ROTATION_TAG: String = "WebTermRotation" } } + +// ── GET /config/ui — the auto-mode host policy ──────────────────────────────────────────────────────── + +/** + * Whether a host permits `approve.mode="acceptEdits"` (`GET /config/ui` → `{ allowAutoMode }`). + * + * `ApiClient.uiConfig()` existed with no caller, so the plan-gate three-way sheet offered + * "approve + auto-accept edits" even on a host whose operator had set `ALLOW_AUTO_MODE=0`. This is the + * client half of that policy, and it **fails CLOSED**: + * - a transport failure, a pre-`/config/ui` server, or an unparseable body all answer `false`; + * - only a SUCCESSFUL fetch is cached, so a host that was merely down is re-asked rather than being + * permanently marked one way or the other; + * - `CancellationException` propagates — a cancelled scope must never be read as a policy answer. + * + * The read-only `GET` carries no `Origin` header (plan §4.3) and no credential of its own; the answer is + * a single boolean, so nothing here has to be redacted. + */ +public class UiConfigGate(private val fetch: suspend (HostEndpoint) -> UiConfig) { + private val mutex = Mutex() + private var cache: Map = emptyMap() + + /** `true` only when [endpoint] positively confirmed it. Never throws except for cancellation. */ + public suspend fun allowsAutoMode(endpoint: HostEndpoint): Boolean { + mutex.withLock { cache[endpoint.baseUrl] }?.let { return it } + val allowed = try { + fetch(endpoint).allowAutoMode + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + return false // FAIL CLOSED — and deliberately NOT cached, so a recovered host is re-asked. + } + mutex.withLock { cache = cache + (endpoint.baseUrl to allowed) } + return allowed + } +} + +// ── Silent certificate rotation: the app-lifecycle trigger ──────────────────────────────────────────── + +/** + * The lifecycle edge that CALLS [CertificateRotationScheduler] — the Android counterpart of iOS + * `AppCoordinator.runCertificateRotationIfDue()`. Without a trigger the scheduler is dead code and a + * device certificate simply expires, stranding the app behind an mTLS handshake it can no longer make. + * + * [fire] launches and RETURNS: the pass does AndroidKeyStore/Tink reads and possibly a control-plane + * round-trip, and no UI path may wait on that. The scheduler is idempotent and self-coalescing, so + * firing on launch and again on every foreground needs no external guard. + * + * Nothing escapes: the scheduler turns a rotation failure into `RotationOutcome.Failed` itself, and + * [onError] only sees the (unexpected) case where the scheduler could not even be resolved. Both report + * the error's SHAPE only — a rotation error can embed a control-plane URL or a response body. + */ +public class CertificateRotationTrigger( + private val scope: CoroutineScope, + private val scheduler: () -> CertificateRotationScheduler, + private val onError: (Throwable) -> Unit = {}, +) { + /** Start a rotation pass in the background. Idempotent by way of the scheduler's single-flight. */ + public fun fire() { + scope.launch { + try { + scheduler().runIfDue() + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + onError(error) + } + } + } +} + +// ── Host removal ────────────────────────────────────────────────────────────────────────────────────── + +/** + * The `DELETE /push/fcm-token` half of [wang.yaojia.webterm.push.PushRegistrar], as a seam so + * [HostRemover] is JVM-testable with no transport. `di/PushModule` binds it to + * `PushRegistrar.unregisterHost` — whose FIRST caller this is. + */ +public fun interface HostPushUnregister { + /** Best-effort: tell [host] to forget [token]. [token] is never logged (plan §8). */ + public suspend fun unregister(host: Host, token: String) +} + +/** This device's current FCM registration token, or null when push is unavailable. */ +public fun interface PushTokenSource { + public suspend fun currentToken(): String? +} + +/** + * Un-pairing a host, as ONE ordered operation. A half-removed host is worse than none, so both the order + * and the failure policy are load-bearing: + * + * 1. **`DELETE /push/fcm-token` FIRST**, while the host record and its `webterm_auth` cookie still + * exist — the call cannot be addressed without the endpoint, nor authenticated without the cookie. + * `PushRegistrar.unregisterHost` had ZERO call sites before this, which is why a dropped host kept + * pushing notifications to the device forever. It is **best-effort**: a host that is switched off + * must still be removable, and the local state is what the user asked us to forget. + * 2. **Then the local state**, in the order that keeps a failure retryable: the session watermarks, the + * last-session pointer, the persisted + live `webterm_auth` cookie, and the paired record LAST. Any + * throw propagates with the host still paired, so the user can simply try again — the UI treats that + * as "nothing was removed" ([HostRemovalGateway]). + * + * ### What is deliberately NOT removed + * - **The device certificate / AndroidKeyStore identity** — it is ONE app-wide mTLS identity shared by + * every host (`IdentityRepository`), not per-host state. Removing it while un-pairing one host would + * silently break every other tunnel host. It has its own explicit affordance on the cert screen. + * - **The quick-reply palette** — global user content, not host state. + * + * ### Watermarks are keyed by SESSION, not host + * `SessionWatermarkStore` maps `sessionId → last-seen`, so there is no host-scoped sweep to run. The + * caller passes the ids it can attribute to this host (its currently-listed sessions); anything it could + * not see is bounded by the store's own 512-entry cap rather than leaking without limit. + */ +public class HostRemover( + private val hostStore: HostStore, + private val lastSessionStore: LastSessionStore, + private val authCookieStore: AuthCookieStore, + private val watermarkStore: SessionWatermarkStore, + private val pushUnregister: HostPushUnregister, + private val currentPushToken: suspend () -> String?, + private val clearLiveCookies: (hostKey: String) -> Unit, + private val log: (String) -> Unit = { Log.w(REMOVER_TAG, it) }, + /** Everything below is storage + network I/O; the caller is a Composable scope on `Main`. */ + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : HostRemovalGateway { + + override suspend fun removeHost(hostId: String, sessionIds: List): Unit = + withContext(ioDispatcher) { + val host = hostStore.loadAll().firstOrNull { it.id == hostId } + ?: return@withContext // already gone → no-op + + unregisterPushBestEffort(host) + + // Local erasure. Ordered so `hostStore.remove` is LAST: an earlier failure leaves the host + // paired, which the UI reports as "nothing removed" and the user can retry. + sessionIds.forEach { watermarkStore.remove(it) } + lastSessionStore.setLastSessionId(null, hostId) + authCookieHostKey(host.endpoint.baseUrl)?.let { hostKey -> + authCookieStore.removeHost(hostKey) + clearLiveCookies(hostKey) // the in-memory jar too, or the credential rides the next dial + } + hostStore.remove(hostId) + } + + /** + * Tell the host to forget this device's push token. Best-effort by design; the token is passed only + * to the call and never logged (plan §8), and a failure is reported by SHAPE. + */ + private suspend fun unregisterPushBestEffort(host: Host) { + val token = try { + currentPushToken() + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + log("push token unavailable while removing a host (${error::class.simpleName})") + null + } ?: return + try { + pushUnregister.unregister(host, token) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + log("push de-registration failed for the removed host (${error::class.simpleName})") + } + } + + private companion object { + const val REMOVER_TAG: String = "WebTermHostRemoval" + } +} + +// ── The WEBTERM_TOKEN gate (server src/http/auth.ts) ───────────────────────────────────────────────── + +/** + * The client half of the server's optional shared-access-token gate. + * + * Server contract (`src/http/auth.ts` + the `/auth` route in `src/server.ts`): + * - with `WEBTERM_TOKEN` set, EVERY remote route (and the WS upgrade) needs the `webterm_auth` cookie; + * an unauthed non-HTML request gets **401** with an `authentication required` JSON body; + * - `POST /auth` is always reachable and rate-limited to 10/min/IP. A non-HTML request answers **204** + * with `Set-Cookie: webterm_auth=…` on a match, **401** on a mismatch, **429** over the limit; + * - `GET /?token=` exists too and is deliberately NOT used here — it would put a shell credential in a + * URL (access logs, `Referer`, history). + * + * The cookie itself is never handled here: the shared client's [AuthCookieJar] captures `Set-Cookie` and + * replays it, so "the submit succeeded" and "the credential is held" are the same event. + * + * @param http resolves the shared [HttpTransport] LAZILY (resolving it builds the mTLS `OkHttpClient`, so + * it must happen inside the calling coroutine, not when this gateway is constructed). + */ +public class AccessTokenGateway(private val http: () -> HttpTransport) { + + /** + * Is [endpoint] behind the token gate? Probes the unauthenticated read and looks for exactly 401. + * + * Never throws for a network problem: an unreachable host answers `false`, so a host that is merely + * down can never make the app ask the user for a credential. Cancellation still propagates. + */ + public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean { + val response = try { + http().send(HttpRequest(method = HttpMethod.GET, url = endpoint.hostBaseUrl() + LIVE_SESSIONS_PATH)) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + return false + } + return response.status == HTTP_UNAUTHORIZED + } + + /** + * `POST /auth` with [token] in an urlencoded form body. + * + * [token] appears in the BODY only — never the URL, never a header, never the returned outcome, never + * a log line — and nothing here retains it. + */ + public suspend fun submit(endpoint: HostEndpoint, token: String): TokenSubmission { + val request = HttpRequest( + method = HttpMethod.POST, + url = endpoint.hostBaseUrl() + AUTH_PATH, + // No `Accept: text/html`: the server answers an HTML request with 302 redirects instead of + // the 204/401 status pair this mapping depends on. No `Origin` either — /auth is registered + // before the gate and is not an Origin-guarded route (plan §4.3: Origin only where required). + headers = mapOf(CONTENT_TYPE_HEADER to FORM_CONTENT_TYPE), + body = "$TOKEN_FIELD=${formEncode(token)}".toByteArray(Charsets.UTF_8), + ) + val response = try { + http().send(request) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + // The TYPE only: an exception message is host-influenced and could echo the request we sent. + return TokenSubmission.Unreachable(reason = error::class.simpleName ?: UNKNOWN_ERROR) + } + return when { + response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES -> TokenSubmission.Accepted + response.status == HTTP_UNAUTHORIZED -> TokenSubmission.Rejected + response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited + else -> TokenSubmission.Unreachable(reason = "HTTP ${response.status}") + } + } + + private companion object { + const val LIVE_SESSIONS_PATH = "/live-sessions" + const val AUTH_PATH = "/auth" + const val TOKEN_FIELD = "token" + const val CONTENT_TYPE_HEADER = "Content-Type" + const val FORM_CONTENT_TYPE = "application/x-www-form-urlencoded" + const val HTTP_OK = 200 + const val HTTP_MULTIPLE_CHOICES = 300 + const val HTTP_UNAUTHORIZED = 401 + const val HTTP_TOO_MANY_REQUESTS = 429 + const val UNKNOWN_ERROR = "unknown" + } +} + +/** + * `scheme://host[:port]` from the dialed URL — path/query/fragment/credentials dropped. Mirrors + * `:api-client`'s probe derivation (private there, hence re-derived rather than shared); the dialed port + * is kept verbatim and IPv6 literals arrive already bracketed from [java.net.URI]. + */ +private fun HostEndpoint.hostBaseUrl(): String { + val uri = URI(baseUrl) + val scheme = (uri.scheme ?: "http").lowercase() + val host = uri.host ?: "" + val portPart = if (uri.port != -1) ":${uri.port}" else "" + return "$scheme://$host$portPart" +} + +/** + * Percent-encode a form-field value, allowing ONLY RFC 3986 unreserved characters through. + * + * Stricter than necessary on purpose: the server's token charset (`[A-Za-z0-9._~+/=-]`) includes `+`, + * `/` and `=`, and in an `application/x-www-form-urlencoded` body a raw `+` decodes to a SPACE while `=` + * would split the field — so a correct token would be rejected. `java.net.URLEncoder` is avoided for the + * same reason (it emits `+` for spaces). + */ +private fun formEncode(value: String): String { + val out = StringBuilder(value.length) + for (byte in value.toByteArray(Charsets.UTF_8)) { + val octet = byte.toInt() and BYTE_MASK + val char = octet.toChar() + if (char.isUnreservedUrlChar()) { + out.append(char) + } else { + // Hand-rolled hex, NOT String.format: `%02X` is locale-sensitive and would emit non-ASCII + // digits under some locales, corrupting the credential. + out.append('%').append(HEX_DIGITS[octet shr HEX_SHIFT]).append(HEX_DIGITS[octet and LOW_NIBBLE]) + } + } + return out.toString() +} + +private fun Char.isUnreservedUrlChar(): Boolean = + this in 'A'..'Z' || this in 'a'..'z' || this in '0'..'9' || this in "-._~" + +private const val HEX_DIGITS: String = "0123456789ABCDEF" +private const val BYTE_MASK: Int = 0xFF +private const val LOW_NIBBLE: Int = 0x0F +private const val HEX_SHIFT: Int = 4 + +// ── Auth-cookie durability bridge (:transport-okhttp ⇄ :host-registry) ─────────────────────────────── + +/** + * Cold-start restore of the durable `webterm_auth` cookie into the live jar, EXACTLY ONCE. + * + * Once, because the durable write is asynchronous ([AuthCookieJar]'s persister hands off): a second + * hydration could overwrite a freshly re-authenticated in-memory cookie with the stale persisted one and + * silently put the app back on a dead credential. + * + * A failure is swallowed (warm-up must never fail on this) but does NOT consume the one shot, so the next + * warm-up retries. Worst case the user re-enters the token — never a crash, never a plaintext fallback. + */ +internal class AuthCookieHydrator( + private val store: () -> AuthCookieStore, + private val jar: () -> AuthCookieJar, +) { + private val hydrated = AtomicBoolean(false) + + /** Reads the store and restores each host's cookies under that host's own key. Call off `Main`. */ + internal suspend fun hydrateOnce() { + if (!hydrated.compareAndSet(false, true)) return + val records = try { + store().loadAll() + } catch (error: Throwable) { + hydrated.set(false) // not consumed — a later warm-up may succeed + if (error is CancellationException) throw error + return + } + val cookieJar = jar() + records.groupBy { it.hostKey }.forEach { (hostKey, forHost) -> + cookieJar.restore(hostKey, forHost.map { it.toSnapshot() }) + } + } +} + +/** + * `:transport-okhttp`'s jar DTO → `:host-registry`'s at-rest record, stamped with the isolation + * [hostKey]. The two modules must not depend on each other (`android/README.md`: dependencies only flow + * down), so `:app` owns this field-for-field mapping. `expiresAtEpochMillis` stays ABSOLUTE — persisting + * a `Max-Age` duration would restart the credential's lifetime on every restore. + */ +internal fun AuthCookieSnapshot.toRecord(hostKey: String): AuthCookieRecord = AuthCookieRecord( + hostKey = hostKey, + name = name, + value = value, + domain = domain, + path = path, + expiresAtEpochMillis = expiresAtEpochMillis, + secure = secure, + httpOnly = httpOnly, + hostOnly = hostOnly, +) + +/** The reverse mapping (cold-start hydration). `hostKey` is dropped — it is the jar's partition key. */ +internal fun AuthCookieRecord.toSnapshot(): AuthCookieSnapshot = AuthCookieSnapshot( + name = name, + value = value, + domain = domain, + path = path, + expiresAtEpochMillis = expiresAtEpochMillis, + secure = secure, + httpOnly = httpOnly, + hostOnly = hostOnly, +) diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/CertificateRotationScheduler.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/CertificateRotationScheduler.kt new file mode 100644 index 0000000..695c0aa --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/CertificateRotationScheduler.kt @@ -0,0 +1,328 @@ +package wang.yaojia.webterm.wiring + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.api.enroll.RotationDecision +import wang.yaojia.webterm.api.enroll.RotationPolicy +import wang.yaojia.webterm.tlsandroid.CertStore +import wang.yaojia.webterm.tlsandroid.DeviceEnroller +import wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot +import wang.yaojia.webterm.tlsandroid.DeviceRotationStore +import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore +import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher +import wang.yaojia.webterm.transport.OkHttpClientFactory +import wang.yaojia.webterm.transport.OkHttpHttpTransport +import wang.yaojia.webterm.wire.HttpTransport +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference + +/** Which step of a rotation pass an outcome refers to — so a failure names what actually failed. */ +public enum class RotationStage { + /** Reading the installed identity's timing out of the encrypted stores. */ + READ_STATE, + + /** `POST /device/:id/renew` over mTLS with the current (still valid) certificate. */ + RENEW, + + /** `POST /device/:id/recover` over plain HTTPS with the lapsed certificate in the body. */ + RECOVER, +} + +/** + * The total result of one rotation pass. Every case is safe to log and to render in the UI: none of + * them can carry a private key, a certificate, a CSR or a token (see [RotationOutcome.Failed]). + */ +public sealed interface RotationOutcome { + /** No enrolled device identity (fresh install / legacy `.p12`): nothing to rotate. */ + public data object NotEnrolled : RotationOutcome + + /** + * Enrolled, but no control-plane URL is persisted (a record written before it was recorded), so + * there is no host to rotate against. Surfaced rather than guessed — a compiled-in default would + * silently talk to the wrong control plane. A re-enroll records the URL and clears this. + */ + public data class NotConfigured(val deviceId: String) : RotationOutcome + + /** The renew window has not opened yet — the cheap, common case. */ + public data class NotDue(val renewAfter: Instant?) : RotationOutcome + + /** An attempt is due but the previous one failed too recently; the next try is at [retryAt]. */ + public data class BackingOff(val retryAt: Instant) : RotationOutcome + + /** Renewed silently; the new leaf is presented on the next handshake. */ + public data object Renewed : RotationOutcome + + /** An expired leaf was recovered over plain HTTPS; the new leaf is live. */ + public data object Recovered : RotationOutcome + + /** + * TERMINAL — the leaf expired [expiredFor] ago, past the recovery grace. No request can succeed; + * only a fresh enroll can. Reported once per pass and never retried, so one real failure cannot + * become thousands of identical warnings. + */ + public data class ReEnrollRequired(val expiredFor: Duration) : RotationOutcome + + /** + * The pass failed at [stage]. The PRIOR identity is fully live and unchanged (the atomic + * live-pointer flip only happens after a successful re-issuance), so a transient failure is + * recoverable on a later pass. + * + * [errorShape] is deliberately the error's SHAPE — its class name, or `Http(status,code)` for a + * server rejection — and never `Throwable.message`, which can embed a URL, a response body or + * credential material. + */ + public data class Failed(val stage: RotationStage, val errorShape: String) : RotationOutcome +} + +/** + * Silent device-certificate rotation: on a trigger (app launch / foreground) read the installed + * identity's timing, ask the pure [RotationPolicy] what to do, and drive it. This is the + * "one bootstrap enroll, then never again" half of zero-touch — the Android counterpart of iOS + * `ClientTLS/CertificateRotationScheduler.swift`, which Android was missing entirely (so a device + * certificate simply expired and stranded the app). + * + * It goes beyond the iOS version in one way that matters: iOS can only renew, and `/device/:id/renew` + * is authenticated by the very certificate it renews — so an iOS device whose leaf lapses is stuck + * needing a manual re-enroll. This driver routes a lapsed leaf to `/device/:id/recover` instead + * ([RotationStage.RECOVER]), which takes the expired certificate in the request BODY over PLAIN + * HTTPS because no TLS terminator will forward an expired client certificate at all. + * + * ### The one place allowed to know both halves + * `:api-client` must never gain a `:client-tls-android` edge. This file is the composition point that + * knows both: the pure policy/HTTP client on one side, the keystore-backed enroller on the other. + * + * ### Idempotent, single-flight, and honest + * - Safe to call on EVERY foreground: `NotDue` is the cheap common case and costs no I/O beyond one + * store read. + * - A second trigger while a pass is in flight COALESCES onto it (an `AtomicReference` holding the + * in-flight result), so two foregrounds cannot stampede two renewals into a control plane that + * rate-limits them. + * - A failure never throws out of [runIfDue]: it is recorded as [RotationOutcome.Failed], published + * on [lastOutcome], and used to throttle the next pass. The prior identity stays fully live — + * `IdentityRepository`'s atomic live-pointer flip guarantees that, and nothing here weakens it. + * - A cancelled pass releases the in-flight slot, so cancellation cannot wedge rotation for the + * lifetime of the process. + * + * ### Threading + * The class itself is dispatcher-free (so every branch is unit-testable under virtual time); the + * store/network hop is inside the seams [create] builds, which move the AndroidKeyStore + Tink reads + * off `Dispatchers.Main`. + * + * ### Step-up policy does NOT apply here + * The relay's step-up gate lives in `relay-auth`'s `enforce/onUpgrade.ts` — the terminal-session + * WebSocket upgrade. Renewal and recovery are plain control-plane HTTPS routes and consult no + * step-up policy, so a step-up-required host does not block certificate rotation. + */ +public class CertificateRotationScheduler( + /** Current rotation timing + target, or null when nothing is enrolled. May throw on a store fault. */ + private val readState: suspend () -> DeviceRotationSnapshot?, + /** `POST /device/:id/renew` over mTLS against the given control-plane base URL. */ + private val renew: suspend (controlPlaneUrl: String) -> Unit, + /** `POST /device/:id/recover` over PLAIN HTTPS against the given control-plane base URL. */ + private val recover: suspend (controlPlaneUrl: String) -> Unit, + private val now: () -> Instant = { Instant.now() }, + private val expiredRecoveryGrace: Duration = RotationPolicy.DEFAULT_EXPIRED_RECOVERY_GRACE, + private val failureBackoff: Duration = RotationPolicy.DEFAULT_FAILURE_BACKOFF, + private val log: (String) -> Unit = { Log.i(TAG, it) }, +) { + private val outcomes = MutableStateFlow(null) + + /** + * The most recent pass's outcome, or null before the first pass. Observable so a silently failing + * renewal becomes visible state (iOS surfaces the same as `isCertificateRenewalFailing`) instead + * of only a log line. + */ + public val lastOutcome: StateFlow = outcomes.asStateFlow() + + /** Written only by the in-flight pass (single-flight); volatile so any dispatcher sees it. */ + @Volatile + private var lastFailureAt: Instant? = null + + /** Non-null while a pass is running — the shared result concurrent triggers coalesce onto. */ + private val inFlight = AtomicReference?>(null) + + /** + * Run one pass: read timing → decide → renew/recover if due. Idempotent and safe on every + * foreground; never throws for a rotation failure (that is [RotationOutcome.Failed]). Only + * cancellation propagates. + */ + public suspend fun runIfDue(): RotationOutcome { + while (true) { + inFlight.get()?.let { return it.await() } + val pass = CompletableDeferred() + if (!inFlight.compareAndSet(null, pass)) continue // lost the race — join the winner + try { + val outcome = runPass() + outcomes.value = outcome + inFlight.set(null) // release BEFORE completing so a later trigger starts a fresh pass + pass.complete(outcome) + return outcome + } catch (throwable: Throwable) { + // Cancellation (the only thing that reaches here) must not leave a slot that no + // future pass can ever get past. + inFlight.set(null) + pass.completeExceptionally(throwable) + throw throwable + } + } + } + + private suspend fun runPass(): RotationOutcome { + val snapshot = try { + readState() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + return recordFailure(RotationStage.READ_STATE, error) + } ?: return RotationOutcome.NotEnrolled + + val state = snapshot.renewalState + val at = now() + val failedAt = lastFailureAt + val decision = RotationPolicy.decide( + state = state, + now = at, + lastFailureAt = failedAt, + expiredRecoveryGrace = expiredRecoveryGrace, + failureBackoff = failureBackoff, + ) + return when (decision) { + RotationDecision.NOT_DUE -> RotationOutcome.NotDue(state.renewAfter) + RotationDecision.BACKING_OFF -> + // The policy only answers BACKING_OFF when a failure was recorded, so `failedAt` is + // present; fall back to `at` rather than assert, so a logic change cannot crash here. + RotationOutcome.BackingOff((failedAt ?: at).plus(failureBackoff)) + RotationDecision.RENEW -> + attempt(RotationStage.RENEW, snapshot, renew, RotationOutcome.Renewed) + RotationDecision.RECOVER -> + attempt(RotationStage.RECOVER, snapshot, recover, RotationOutcome.Recovered) + RotationDecision.RE_ENROLL_REQUIRED -> reEnrollRequired(snapshot, at) + } + } + + /** + * Run one re-issuance [operation] against the persisted control plane. The operation is passed in + * rather than selected from [stage] so there is no unreachable "stage that is not an attempt" + * branch to get wrong. + */ + private suspend fun attempt( + stage: RotationStage, + snapshot: DeviceRotationSnapshot, + operation: suspend (controlPlaneUrl: String) -> Unit, + success: RotationOutcome, + ): RotationOutcome { + val deviceId = snapshot.renewalState.deviceId + val controlPlaneUrl = snapshot.controlPlaneUrl.trim() + if (controlPlaneUrl.isEmpty()) { + log("rotation: device $deviceId has no persisted control-plane URL; re-enroll to record it") + return RotationOutcome.NotConfigured(deviceId) + } + return try { + operation(controlPlaneUrl) + lastFailureAt = null + log("rotation: device $deviceId certificate ${stage.name.lowercase()} succeeded") + success + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + recordFailure(stage, error) + } + } + + private fun reEnrollRequired(snapshot: DeviceRotationSnapshot, at: Instant): RotationOutcome { + val notAfter = snapshot.renewalState.notAfter + val expiredFor = if (notAfter == null) Duration.ZERO else Duration.between(notAfter, at) + log( + "rotation: device ${snapshot.renewalState.deviceId} certificate expired ${expiredFor.toDays()}d ago, " + + "past the ${expiredRecoveryGrace.toDays()}d recovery window; a fresh enroll is required", + ) + return RotationOutcome.ReEnrollRequired(expiredFor) + } + + /** + * Record + report a failure. The prior identity is untouched, so the next pass can succeed. Only + * the error's SHAPE is kept — never `message`, which can embed a URL, a body or a credential. + */ + private fun recordFailure(stage: RotationStage, error: Exception): RotationOutcome { + lastFailureAt = now() + val shape = errorShapeOf(error) + log("rotation: ${stage.name.lowercase()} failed ($shape); the current certificate stays live") + return RotationOutcome.Failed(stage, shape) + } + + public companion object { + private const val TAG = "CertRotation" + + /** + * The error's non-secret shape. A server rejection keeps its status + uniform `error` code + * (401 = expired beyond grace / untrusted, 403 = revoked or mismatched, 429 = rate limited), + * which is exactly the diagnostic value needed; everything else degrades to the class name. + */ + private fun errorShapeOf(error: Exception): String = when (error) { + is DeviceEnrollmentError.Http -> + "Http(${error.status}${error.code?.let { ",$it" } ?: ""})" + else -> error.javaClass.simpleName + } + + /** + * Production wiring — the single composition point, so a DI module needs one provider. + * + * [mtlsTransport] is the app's shared REST transport, which presents the current device + * certificate; [plainTransport] builds a SEPARATE client with NO client identity, used only by + * the recovery path. That separation is load-bearing: an expired leaf presented on a handshake + * makes nginx answer a bare 400 (it will not forward an expired client cert in any + * `ssl_verify_client` mode), which is the deadlock recovery exists to escape. The plain client + * is built lazily, so the common (renew) path never pays for it. + * + * Store reads and both HTTP calls are hopped onto [ioDispatcher] — the first identity touch + * does synchronous AndroidKeyStore + Tink work and must never run on `Dispatchers.Main`. + * + * [sharedClient] / [mtlsTransport] / [plainTransport] are SUPPLIERS, not values, for the same + * reason [EnrollmentFlowFactory] takes `dagger.Lazy`: resolving the shared client builds the + * mTLS `SSLSocketFactory` (keystore I/O). As suppliers they are invoked only inside the + * [ioDispatcher] hop, so a DI provider for this scheduler is itself I/O-free and can be + * resolved from the main thread without risking an ANR. + */ + public fun create( + certStore: CertStore, + recordStore: EnrollmentRecordStore, + cacheRefresher: IdentityCacheRefresher, + sharedClient: () -> OkHttpClient, + mtlsTransport: () -> HttpTransport, + plainTransport: () -> HttpTransport = { OkHttpHttpTransport(OkHttpClientFactory.create()) }, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + now: () -> Instant = { Instant.now() }, + log: (String) -> Unit = { Log.i(TAG, it) }, + ): CertificateRotationScheduler { + val rotationStore = DeviceRotationStore(certStore, recordStore) + fun enroller(controlPlaneUrl: String, withRecovery: Boolean): DeviceEnroller = + DeviceEnroller( + client = DeviceEnrollmentClient(controlPlaneUrl, mtlsTransport()), + certStore = certStore, + recordStore = recordStore, + sharedClient = sharedClient(), + cacheRefresher = cacheRefresher, + recoveryClient = + if (withRecovery) DeviceEnrollmentClient(controlPlaneUrl, plainTransport()) else null, + ) + return CertificateRotationScheduler( + readState = { withContext(ioDispatcher) { rotationStore.read() } }, + renew = { url -> withContext(ioDispatcher) { enroller(url, withRecovery = false).renew() } }, + recover = { url -> withContext(ioDispatcher) { enroller(url, withRecovery = true).recover() } }, + now = now, + log = log, + ) + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt index a227af3..5b369f6 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt @@ -62,8 +62,10 @@ public class TerminalSessionControllerImpl( ) : 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. + * The forked Termux emulator for this session (no local process). The screen only puts it on-screen + * (`attachView`) / takes it off (`detachView`); the grid is driven from INSIDE `:terminal-view` + * (`RemoteTerminalHostView.onSizeChanged` → `TerminalResizeDriver` → `updateSize`), so nothing in `:app` + * computes cols×rows. A21's own two jobs are feeding it the remote output and routing its replies out. */ public val remoteSession: RemoteTerminalSession = remoteSessionFactory(::routeEmulatorMessage) { raw -> onSanitizedTitle(TitleSanitizer.sanitize(raw)) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt index 56aa575..9b874f0 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt @@ -24,56 +24,129 @@ import kotlin.math.ceil * * `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. + * `TerminalRenderer` derives them — as a TEMPLATE that [ThumbnailBudget.cellMetrics] then shrinks to the + * thumbnail budget, so the raster is born at roughly the size the card displays. + * + * ### Two bounds, both server-input driven (both are pure and JVM-tested) + * - [ThumbnailGrid] clamps `cols`/`rows` (SERVER-supplied; `src/protocol.ts` validates resize only to + * 1..1000) and — load-bearing — derives the emulator's `transcriptRows` so it is never below the screen + * height. Termux's `TerminalBuffer.externalToInternalRow` is `(mScreenFirstRow + row) % mTotalRows`, so + * a `transcriptRows < rows` buffer ALIASES external rows onto each other and the thumbnail draws + * duplicated rows (this was the bug: a fixed `TERMINAL_TRANSCRIPT_ROWS_MIN` = 100 against `rows` ≤ 200). + * - [ThumbnailBudget.cellMetrics] shrinks the CELL so the full-resolution intermediate can never exceed + * [ThumbnailBudget.MAX_WIDTH_PX] × [ThumbnailBudget.MAX_HEIGHT_PX] (≤ 900 KiB ARGB_8888) — previously a + * legitimately-sized 1000×1000 session forced a 31 MB allocation per render, ×2 concurrent permits, on + * every 5 s poll tick, which degrades safely (OOM is caught) but invites an LMK kill of the whole app. * * 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. + * JVM-tested via the [ThumbnailRasterizer] seam, and its two size bounds via [ThumbnailGrid] / + * [ThumbnailBudget] (pure) plus a real off-screen [TerminalEmulator] (pure Java — no android.graphics). */ public class CanvasThumbnailRasterizer( private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX, typeface: Typeface = Typeface.MONOSPACE, + private val maxWidthPx: Int = ThumbnailBudget.MAX_WIDTH_PX, + private val maxHeightPx: Int = ThumbnailBudget.MAX_HEIGHT_PX, ) : ThumbnailRasterizer { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + /** + * METRICS ONLY — never used to draw. `ThumbnailPipeline` renders up to `MAX_CONCURRENT_RENDERS` (2) + * thumbnails at once on ONE rasterizer instance, so a shared draw `Paint` would be mutated + * (colour/bold/style per cell) by two coroutines concurrently — a data race producing wrong colours or + * a torn draw. Each [rasterize] therefore copies this template into its own local `Paint`. + */ + private val metricsPaint = 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 + + /** The UNSCALED cell size at [fontSizePx]; [ThumbnailBudget.cellMetrics] shrinks it per render. */ + private val templateCellWidth: Int = ceil(metricsPaint.measureText("X").toDouble()).toInt().coerceAtLeast(1) + private val templateCellHeight: Int = + (metricsPaint.fontMetricsInt.descent - metricsPaint.fontMetricsInt.ascent).coerceAtLeast(1) + private val templateBaseline: Int = -metricsPaint.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 grid = ThumbnailGrid.of(preview.cols, preview.rows) + val cell = ThumbnailBudget.cellMetrics( + cols = grid.cols, + rows = grid.rows, + templateWidth = templateCellWidth, + templateHeight = templateCellHeight, + maxWidth = maxWidthPx, + maxHeight = maxHeightPx, + ) + val emulator = TerminalEmulator(NoOpOutput, grid.cols, grid.rows, grid.transcriptRows, 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) + // Per-render Paint (see [metricsPaint]); its text size follows the shrunken cell, so a glyph never + // smears across the neighbouring cells of a downsized grid. + val paint = Paint(metricsPaint).apply { textSize = fontSizePx * cell.textScale } + val geometry = RenderGeometry( + cols = grid.cols, + cellWidth = cell.width, + cellHeight = cell.height, + baseline = (templateBaseline * cell.textScale).toInt().coerceIn(1, cell.height), + ) + val full = Bitmap.createBitmap( + grid.cols * cell.width, + grid.rows * cell.height, + Bitmap.Config.ARGB_8888, + ) + val canvas = Canvas(full) 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) + for (row in 0 until grid.rows) { + drawRow(canvas, paint, emulator, row, geometry, palette, defaultBg) } - return bitmap + return downscaleToBudget(full) } override fun placeholder(): Bitmap { - val bitmap = Bitmap.createBitmap(cellWidth, cellHeight, Bitmap.Config.ARGB_8888) + val bitmap = Bitmap.createBitmap(templateCellWidth, templateCellHeight, 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) { + /** + * Safety net over [ThumbnailBudget.cellMetrics]: the cell shrink already keeps the raster inside the + * budget, so this is normally a no-op (`scaledSize` returns `null` ⇒ no second allocation). It stays + * because it is the ONE place that bounds a bitmap that arrived larger than the budget by any route, + * and it recycles the oversized source immediately rather than caching multiple MiB per session. + */ + private fun downscaleToBudget(full: Bitmap): Bitmap { + val target = ThumbnailBudget.scaledSize(full.width, full.height, maxWidthPx, maxHeightPx) ?: return full + val scaled = Bitmap.createScaledBitmap(full, target.width, target.height, true) + if (scaled !== full) full.recycle() + return scaled + } + + /** Immutable per-render draw geometry (the grid width plus the px size of one cell). */ + private data class RenderGeometry( + val cols: Int, + val cellWidth: Int, + val cellHeight: Int, + val baseline: Int, + ) + + private fun drawRow( + canvas: Canvas, + paint: Paint, + emulator: TerminalEmulator, + row: Int, + geometry: RenderGeometry, + 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 top = (row * geometry.cellHeight).toFloat() + val baseline = (row * geometry.cellHeight + geometry.baseline).toFloat() + for (col in 0 until geometry.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 @@ -83,11 +156,11 @@ public class CanvasThumbnailRasterizer( var bg = resolveColor(TextStyle.decodeBackColor(style), palette, boldBright = false) if (inverse) { val swap = fg; fg = bg; bg = swap } - val left = (col * cellWidth).toFloat() + val left = (col * geometry.cellWidth).toFloat() if (bg != defaultBg) { paint.color = bg paint.style = Paint.Style.FILL - canvas.drawRect(left, top, left + cellWidth, top + cellHeight, paint) + canvas.drawRect(left, top, left + geometry.cellWidth, top + geometry.cellHeight, paint) } val start = line.findStartOfColumn(col) val end = line.findStartOfColumn(col + 1) @@ -111,13 +184,9 @@ public class CanvasThumbnailRasterizer( } public companion object { - /** Default glyph size for a thumbnail (px). Small — the card downscales anyway. */ + /** Default glyph size for a thumbnail (px). Small — the cell shrink and the card scale it 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 @@ -126,6 +195,148 @@ public class CanvasThumbnailRasterizer( } } +/** + * The off-screen emulator's GRID, derived from a preview's SERVER-supplied geometry. Pure, so the two + * things that are easy to get catastrophically wrong are JVM-testable (android.graphics is stubbed + * off-device). + * + * `cols`/`rows` come off the wire: `src/protocol.ts` validates a resize only to 1..1000, so a preview may + * legitimately claim 1000×1000 and the grid must be clamped before anything is allocated from it. + */ +public object ThumbnailGrid { + /** + * Grid clamp, tied to the raster budget rather than picked by feel: one cell can never be narrower or + * shorter than 1 px, so a grid wider than [ThumbnailBudget.MAX_WIDTH_PX] (or taller than + * [ThumbnailBudget.MAX_HEIGHT_PX]) could not fit the budget at ANY cell size. Everything a real + * terminal reports — 80×24 up to a 4K-monitor 400×120 — is far inside this, so the clamp only ever + * crops geometry no terminal actually has. + */ + public const val MAX_COLS: Int = ThumbnailBudget.MAX_WIDTH_PX + public const val MAX_ROWS: Int = ThumbnailBudget.MAX_HEIGHT_PX + + /** The clamped screen grid plus the emulator's scrollback depth. [transcriptRows] is always ≥ [rows]. */ + public data class Grid(val cols: Int, val rows: Int, val transcriptRows: Int) + + /** + * Clamp [cols]/[rows] into the budget and pick the emulator's `transcriptRows`. + * + * **`transcriptRows` MUST be ≥ `rows`.** Termux's `TerminalBuffer` keeps `mTotalRows = transcriptRows` + * and maps `externalToInternalRow(row) = (mScreenFirstRow + row) % mTotalRows`, so a buffer with fewer + * total rows than screen rows silently aliases external row *n* onto *n − mTotalRows* — the thumbnail + * would draw duplicated rows and the emulator would scroll against invalid state during `append`. + * The floor is `TERMINAL_TRANSCRIPT_ROWS_MIN` because `TerminalEmulator` itself rejects anything below + * it (falling back to its 2000-row default), which is more scrollback than a preview ever needs. + */ + public fun of(cols: Int, rows: Int): Grid { + val clampedRows = rows.coerceIn(1, MAX_ROWS) + return Grid( + cols = cols.coerceIn(1, MAX_COLS), + rows = clampedRows, + transcriptRows = clampedRows.coerceAtLeast(TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN), + ) + } +} + +/** + * The pure raster-size budget for a preview thumbnail: how large a rasterised terminal screen may be, both + * as drawn ([cellMetrics]) and as kept ([scaledSize]). Split out of [CanvasThumbnailRasterizer] so the + * arithmetic is JVM-unit-testable (android.graphics is stubbed off-device), while the `Bitmap`/`Canvas` + * work stays device-QA. + * + * All of it is integer arithmetic on purpose: a float `floor` could round a cell size *up* past the bound + * it is supposed to enforce, and the bound is the thing standing between a rogue geometry and an LMK kill. + */ +public object ThumbnailBudget { + /** + * Max thumbnail width (px). The row tile is ~96dp (≈288px at 3× density) and drawn with + * `ContentScale.Crop`, so 480px keeps it crisp on any density. + */ + public const val MAX_WIDTH_PX: Int = 480 + + /** + * Max thumbnail height (px). A terminal screen is wider than tall (80×24 lands near 480×288), so this + * only binds a tall/narrow session — but it MUST exist: with a width-only bound a 20×200 session was + * unbounded in height (it rasterised to 140×2800 = 1.5 MiB and was cached at full size, because its + * width was already inside the budget). + */ + public const val MAX_HEIGHT_PX: Int = 480 + + /** A raster target size in px. */ + public data class RasterSize(val width: Int, val height: Int) + + /** The px size of ONE cell, plus the text-size factor that keeps glyphs inside it. */ + public data class CellMetrics(val width: Int, val height: Int, val textScale: Float) + + /** + * The cell size to draw [cols]×[rows] at, shrunk from the font's own [templateWidth]×[templateHeight] + * so the whole raster fits [maxWidth]×[maxHeight] — the screen is never cropped to fit, the cells are. + * + * Guarantees, for `cols ≤ maxWidth` and `rows ≤ maxHeight` (which [ThumbnailGrid] enforces): + * - `cols * width ≤ maxWidth` and `rows * height ≤ maxHeight` — so the ARGB_8888 intermediate is at + * most `maxWidth * maxHeight * 4` bytes (900 KiB at the defaults) whatever the server reports; + * - the cell aspect ratio is preserved (both axes take the SAME scale factor), so text keeps its + * shape instead of being squeezed on one axis; + * - never upscales (the factor is capped at 1) and never returns a 0-px cell. + */ + public fun cellMetrics( + cols: Int, + rows: Int, + templateWidth: Int, + templateHeight: Int, + maxWidth: Int = MAX_WIDTH_PX, + maxHeight: Int = MAX_HEIGHT_PX, + ): CellMetrics { + val cellWidth = templateWidth.coerceAtLeast(1) + val cellHeight = templateHeight.coerceAtLeast(1) + val scale = fitScale( + width = cols.coerceAtLeast(1).toLong() * cellWidth, + height = rows.coerceAtLeast(1).toLong() * cellHeight, + maxWidth = maxWidth, + maxHeight = maxHeight, + ) + return CellMetrics( + width = scale.scaled(cellWidth), + height = scale.scaled(cellHeight), + textScale = scale.factor, + ) + } + + /** + * The size [width]×[height] should be scaled to, or `null` when it already fits [maxWidth]×[maxHeight] + * (draw as rendered — no extra allocation). Aspect ratio is preserved (the tighter axis wins) and + * neither side ever collapses to 0. + */ + public fun scaledSize( + width: Int, + height: Int, + maxWidth: Int = MAX_WIDTH_PX, + maxHeight: Int = MAX_HEIGHT_PX, + ): RasterSize? { + if (width <= 0 || height <= 0) return null + if (width <= maxWidth && height <= maxHeight) return null + val scale = fitScale(width.toLong(), height.toLong(), maxWidth, maxHeight) + return RasterSize(width = scale.scaled(width), height = scale.scaled(height)) + } + + /** + * The largest factor ≤ 1 that fits [width]×[height] inside [maxWidth]×[maxHeight], as an EXACT fraction + * (see the class note on integer arithmetic — a float would round a size up past the bound it enforces). + */ + private fun fitScale(width: Long, height: Long, maxWidth: Int, maxHeight: Int): Scale = when { + width <= maxWidth && height <= maxHeight -> Scale(1L, 1L) + maxWidth.toLong() * height <= maxHeight.toLong() * width -> Scale(maxWidth.toLong(), width) + else -> Scale(maxHeight.toLong(), height) + } + + /** A shrink factor `num/den` (≤ 1), applied to px sizes by exact integer division. */ + private data class Scale(private val num: Long, private val den: Long) { + /** [px] shrunk by this factor, floored, never below 1 px. */ + fun scaled(px: Int): Int = (px * num / den).toInt().coerceAtLeast(1) + + val factor: Float get() = num.toFloat() / den.toFloat() + } +} + /** 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 diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt index cd81fa5..363bb3f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt @@ -6,17 +6,24 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout 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 +import java.util.concurrent.ConcurrentHashMap /** * Off-screen terminal-preview rasterisation for the session-list / manage-page thumbnails (plan §6.7). @@ -28,7 +35,7 @@ import java.util.UUID * [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) + * ### The 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] @@ -37,8 +44,16 @@ import java.util.UUID * grid of many cards never spawns dozens of concurrent emulators / large-bitmap allocations. * - **In-flight dedup** — a `Map>` 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. + * - **Supersede (coalesce per session)** — `lastOutputAt` advances on every PTY output and the list polls + * every 5 s, so an ACTIVE session mints a new key on every tick. Only the newest key per session id may + * be in flight: a newer key CANCELS the older render, which both frees its permit for the key the row is + * actually showing (otherwise obsolete work queues ahead of it and the tile visibly lags) and stops the + * unbounded growth of `inFlight` once per-render latency exceeds the 5 s arrival rate on a slow link. + * - **Timeout** — the fetch+render of one thumbnail is bounded (see [DEFAULT_RENDER_TIMEOUT_MS]). Without + * it two slow-trickle previews hold both permits forever (OkHttp sets no `callTimeout`, and its + * per-socket read timeout is reset by every trickled byte) and NO thumbnail renders again until [close]. + * - **Failure fallback** — any fetch/render failure, timeout included, 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. @@ -49,6 +64,7 @@ public class ThumbnailPipeline( private val cache: BitmapCache = LruBitmapCache(DEFAULT_CACHE_BYTES), dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Default, maxConcurrent: Int = MAX_CONCURRENT_RENDERS, + private val renderTimeoutMs: Long = DEFAULT_RENDER_TIMEOUT_MS, ) { private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) @@ -56,16 +72,36 @@ public class ThumbnailPipeline( 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>() + private val inFlight = HashMap>() 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). + * The newest key requested per session id — the supersede rule's state, and the cache-publish gate. + * Concurrent (not [inFlightMutex]-guarded) because [retainOnly] is a plain, non-suspending call from a + * `LaunchedEffect` and must be able to drop a killed session's entry without a coroutine. */ - public suspend fun thumbnail(session: LiveSessionInfo): Bitmap { + private val latestKeyBySession = ConcurrentHashMap() + + /** + * Orders a cache PUBLISH against a concurrent [retainOnly] prune, so a render that finishes after its + * session was pruned cannot re-insert a bitmap the prune just dropped. Always the INNERMOST lock (never + * held across a suspension), so it cannot participate in a cycle with [inFlightMutex]. + */ + private val publishLock = Any() + + /** + * The cached (or freshly rendered) thumbnail for [session], or `null` when no bitmap at all could be + * produced. 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 fetch/raster failure or a timeout caches the + * rasterizer's placeholder (no retry storm), and the residual case where even the placeholder cannot be + * allocated (a `Bitmap` OOM) degrades to `null` rather than an exception: the caller is a row's + * `LaunchedEffect`, so a throw would take down the composition. Only cancellation of the CALLER + * propagates; a render killed by [close] or superseded by a newer key for the same session also + * surfaces as `null` (the caller is still alive — it just has no thumbnail for this key). + */ + 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 } @@ -74,9 +110,40 @@ public class ThumbnailPipeline( // 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 } + // A CLOSED pipeline must not insert: `scope.async` on a cancelled scope never runs the body, so + // the finally that frees the slot never runs and the key would keep a dead Deferred forever + // (every later request awaiting it for null, and `inFlight` growing by every distinct key). + if (!scope.isActive) return null + supersedeOlderRenders(key) inFlight[key] ?: startRender(key).also { inFlight[key] = it } } - return deferred.await() + return try { + deferred.await() + } catch (cancel: CancellationException) { + // Distinguish "the render was closed/superseded under us" (→ no thumbnail) from "OUR caller was + // cancelled" (→ propagate, so the collector tears down as structured concurrency requires). + if (currentCoroutineContext().isActive) null else throw cancel + } + } + + /** + * Release every cached bitmap that is not part of the CURRENT live list — killed/exited sessions and + * superseded `lastOutputAt` keys — so the cache holds only what the chooser can still display. The + * [LruCache] byte budget already bounds worst-case memory; this returns the memory promptly instead of + * waiting for eviction pressure, and keeps dead sessions from occupying the budget at all. + * + * A render still in flight for a session that just disappeared can no longer re-insert its bitmap after + * this prune: dropping the session's [latestKeyBySession] entry closes the publish gate, and both sides + * run under [publishLock] so the check cannot straddle the prune. (A caller that KEEPS asking for a + * pruned session is a different thing and still caches — it asked.) + */ + public fun retainOnly(live: Collection) { + val liveIds = live.mapTo(HashSet()) { it.id } + val liveKeys = live.mapTo(HashSet()) { ThumbnailKey(it.id, it.lastOutputAt) } + synchronized(publishLock) { + latestKeyBySession.keys.retainAll(liveIds) + cache.retainOnly(liveKeys) + } } /** Cancel all in-flight renders and release the pipeline scope (call from the owner's teardown). */ @@ -84,40 +151,133 @@ public class ThumbnailPipeline( scope.cancel() } - private fun startRender(key: ThumbnailKey): Deferred = 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 + /** Test-only: live in-flight entries. A non-zero count after [close] would be a leaked key. */ + internal suspend fun inFlightCount(): Int = inFlightMutex.withLock { inFlight.size } + + /** + * Make [key] the session's newest key and abandon any older in-flight render for the SAME session — the + * supersede rule. Caller MUST hold [inFlightMutex]; `Deferred.cancel` does not suspend, so cancelling + * from inside the critical section is safe (the render's own `finally` takes the mutex afterwards). + * + * An out-of-order request for an OLDER screen than one already in flight is left alone: it still renders + * and returns a bitmap to its caller, but it does not become the newest key, so the publish gate keeps + * its result out of the cache instead of overwriting fresher work. + */ + private fun supersedeOlderRenders(key: ThumbnailKey) { + val previous = latestKeyBySession[key.sessionId] + if (previous == key) return + if (previous != null && depictsOlderScreen(key, previous)) return + latestKeyBySession[key.sessionId] = key + previous?.let { inFlight.remove(it)?.cancel() } } - private suspend fun renderGuarded(sessionId: UUID): Bitmap = + private fun startRender(key: ThumbnailKey): Deferred = scope.async { + var rendered: Bitmap? = null + try { + rendered = renderGuarded(key.sessionId) + rendered + } finally { + // Release the in-flight slot on EVERY path the body can leave by — normal return, cancellation + // (closed/superseded) and an unexpected throw — because a retained entry would poison the key: + // every later request for it would await a dead Deferred forever. NonCancellable so the cleanup + // still runs while cancelling. (The one path that cannot run this is a scope cancelled between + // the `isActive` check in [thumbnail] and `scope.async` itself, where the body never starts — + // the pipeline is closed by then and this whole map is discarded with it.) + withContext(NonCancellable) { + inFlightMutex.withLock { + // Publish under the same lock, so a concurrent [thumbnail] re-check sees the result the + // instant the key stops being in-flight. Success AND placeholder cache identically + // (§6.7 no-retry-storm); an unproducible bitmap caches nothing and may be retried. + publish(key, rendered) + inFlight.remove(key) + } + } + } + } + + /** + * Cache [bitmap] under [key] unless the key is no longer the one the UI wants: superseded by newer + * output for the same session, or pruned by [retainOnly] because the session is gone. Caching either + * would spend the LRU budget on a bitmap nothing will ever request again. + */ + private fun publish(key: ThumbnailKey, bitmap: Bitmap?) { + if (bitmap == null) return + synchronized(publishLock) { + if (latestKeyBySession[key.sessionId] != key) return + cache.put(key, bitmap) + } + } + + private suspend fun renderGuarded(sessionId: UUID): Bitmap? = try { renderGate.withPermit { - val preview = previewSource.fetch(sessionId) - rasterizer.rasterize(preview) + // The timeout is INSIDE the permit so it budgets the work, not the wait for a permit, and + // the catch/fallback is OUTSIDE it so no placeholder is ever allocated holding a permit. + withTimeout(renderTimeoutMs) { + val preview = previewSource.fetch(sessionId) + rasterizer.rasterize(preview) + } } + } catch (timeout: TimeoutCancellationException) { + // MUST precede the CancellationException branch — a timeout IS one. Rethrowing it would turn a + // slow host into a permanently dead feature: the two permits are the only ones there are, and a + // render is deliberately not cancellable by any caller. + placeholderOrNull() } catch (e: CancellationException) { - throw e // never swallow cooperative cancellation + throw e // never swallow cooperative cancellation (closed pipeline / superseded key) } 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() + // so the broken session isn't retried on every scroll frame (§6.7). If even the placeholder + // cannot be allocated, give up with null — never throw at a UI collector. + placeholderOrNull() } + /** + * The failure placeholder, or `null` when even that cannot be allocated (a `Bitmap` OOM). The failure + * itself is deliberately NOT logged: a preview failure can carry attacker-influenced text, and logcat + * must never see preview bytes (plan §8). + */ + private fun placeholderOrNull(): Bitmap? = runCatching { rasterizer.placeholder() }.getOrNull() + + /** `true` when [key] depicts an older screen than [other] (a smaller server `lastOutputAt`). */ + private fun depictsOlderScreen(key: ThumbnailKey, other: ThumbnailKey): Boolean = + (key.lastOutputAt ?: 0L) < (other.lastOutputAt ?: 0L) + 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 + + /** + * Budget for ONE fetch+render once it holds a permit. Generous next to a small preview GET over the + * shared client (OkHttp's own defaults are 10 s connect / 10 s per-read) — this is the liveness + * backstop for the trickle case those per-socket timeouts cannot catch, not a latency target. + */ + public const val DEFAULT_RENDER_TIMEOUT_MS: Long = 15_000L } } +/** + * Build the production preview pipeline for ONE host: the read-only [ApiPreviewSource] over that host's + * [ApiClient] (the shared mTLS `OkHttpClient`) feeding the [CanvasThumbnailRasterizer] off-screen painter, + * with the default LRU budget / `Semaphore(2)` cap / `Dispatchers.Default` render pool. + * + * This is the ONE construction point (`SessionsHome` wires the active host through it), so nothing else + * has to know which rasterizer or which cache the session chooser uses. [api] is a PROVIDER and is + * resolved lazily on the first fetch — inside the pipeline's own background dispatcher — so calling this + * from a composition does NO `OkHttpClient` / AndroidKeyStore / Tink work on `Main`. + * + * The owner MUST call [ThumbnailPipeline.close] when the surface goes away (the render scope outlives any + * single caller by design). + */ +public fun sessionThumbnailPipeline(api: () -> ApiClient): ThumbnailPipeline = + ThumbnailPipeline( + previewSource = ApiPreviewSource(api), + rasterizer = CanvasThumbnailRasterizer(), + ) + /** * 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` @@ -144,10 +304,17 @@ public interface ThumbnailRasterizer { public fun placeholder(): Bitmap } -/** The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable. */ +/** + * The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable. + * Implementations MUST be thread-safe: [ThumbnailPipeline] reads it from an unsynchronised fast path and + * writes it from its render coroutines ([LruCache] is internally synchronised). + */ public interface BitmapCache { public fun get(key: ThumbnailKey): Bitmap? public fun put(key: ThumbnailKey, bitmap: Bitmap) + + /** Drop every entry whose key is not in [keys] (dead sessions / superseded `lastOutputAt`). */ + public fun retainOnly(keys: Set) } /** @@ -164,27 +331,57 @@ public class LruBitmapCache(maxBytes: Int) : BitmapCache { override fun put(key: ThumbnailKey, bitmap: Bitmap) { lru.put(key, bitmap) } + + /** `snapshot()` is a copy, so removing while iterating it is safe (and `remove` is synchronised). */ + override fun retainOnly(keys: Set) { + for (key in lru.snapshot().keys) { + if (key !in keys) lru.remove(key) + } + } } /** * 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). + * tunnel-host previews traverse nginx). `GET /live-sessions/:id/preview` is a READ-ONLY route: it does NOT + * attach, register a client, or touch the session's idle clock. Bounds the emulator's input with + * [PreviewCap] (plan §6.7 / §4.2 — read its note on what that bound can and cannot do). + * + * [api] is a PROVIDER, not a client: resolving it resolves the shared `HttpTransport`, which builds the one + * `OkHttpClient` and first-touches the AndroidKeyStore/Tink mTLS material. That must never happen on the + * composition (`Main`) thread, so the client is materialised lazily inside [fetch], which runs on the + * pipeline's own background dispatcher (plan §"off-`Main` warm-up"). */ -public class ApiPreviewSource(private val apiClient: ApiClient) : PreviewSource { +public class ApiPreviewSource(api: () -> ApiClient) : PreviewSource { + private val apiClient: ApiClient by lazy(api) + 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. */ +/** + * Client-side bound on how much preview text reaches the off-screen emulator. Pure — JVM-testable. + * + * **What this is NOT:** it is not a defence against an oversized RESPONSE BODY. By the time it runs, the + * transport has already buffered the whole body into a `ByteArray` (`OkHttpHttpTransport` calls + * `body.bytes()`) and `ApiClient.preview` has decoded that into JSON and a `String` — so a rogue multi-MB + * body has already been allocated two or three times over, and capping here allocates one more copy. A real + * body bound has to live in the transport (an OkHttp response-body byte limit), which this class cannot + * reach; until then, the cap here only bounds the *downstream* cost: the VT parse in + * [TerminalEmulator][com.termux.terminal.TerminalEmulator]`.append` and the retained tail string. + */ public object PreviewCap { - /** 256 KiB — mirrors the server's `GET /live-sessions/:id/preview` data cap. */ + /** + * 256 KiB. This is the CLIENT's own ceiling, not a mirror of the server's: `src/config.ts` defaults + * `PREVIEW_BYTES` to **24 KiB** and lets an operator raise it with no upper bound, so this sits ~10× the + * default — big enough never to clip a normally-configured host's tail, small enough to keep the VT + * parse per poll tick bounded whatever a host is configured to (or lies about) sending. + */ 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. + * so a 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) diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index a58ffec..77738ff 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -1,10 +1,84 @@ - + - + + + + + + + + + + + terminal.yaojia.wang + + diff --git a/android/app/src/test/java/wang/yaojia/webterm/NetworkSecurityConfigTest.kt b/android/app/src/test/java/wang/yaojia/webterm/NetworkSecurityConfigTest.kt new file mode 100644 index 0000000..f783575 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/NetworkSecurityConfigTest.kt @@ -0,0 +1,112 @@ +package wang.yaojia.webterm + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.w3c.dom.Element +import org.w3c.dom.Document +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Regression lock for the A19 cleartext posture (BLOCKER 3). + * + * The shipped `network_security_config.xml` used to be a STUB that denied cleartext globally, which made + * every bare-LAN `ws://` connect impossible on a real device — while `HostEndpoint.fromBaseUrl` accepts + * `http` and `PairingViewModel` treats RFC1918 as a first-class tier. Bare LAN is this product's PRIMARY + * use case (CLAUDE.md "What This Is"), so that contradiction made the app tunnel-only. + * + * These are resource-shape assertions, deliberately NOT a behavioural test: the behaviour they guard + * (does a socket to 192.168.x.x open?) is only observable on a device and lives in DEVICE_QA_CHECKLIST. + * What CAN regress silently in a code review is the XML, so that is what is pinned here. Each assertion + * corresponds to a way the fix could be undone without any other test going red. + */ +@DisplayName("network_security_config.xml — A19 cleartext posture") +class NetworkSecurityConfigTest { + + private fun parse(path: String): Document = + DocumentBuilderFactory.newInstance() + .apply { isNamespaceAware = true } + .newDocumentBuilder() + .parse(File(path)) + + private fun nsc(): Document = parse("src/main/res/xml/network_security_config.xml") + + private fun elements(doc: Document, tag: String): List { + val nodes = doc.getElementsByTagName(tag) + return (0 until nodes.length).map { nodes.item(it) as Element } + } + + @Test + @DisplayName("base-config permits cleartext, so a user-typed LAN address can still connect") + fun baseConfigPermitsCleartext() { + val base = elements(nsc(), "base-config").single() + assertEquals( + "true", + base.getAttribute("cleartextTrafficPermitted"), + "Denying cleartext in base-config makes every ws:// LAN connect fail with " + + "UnknownServiceException. The platform has no CIDR syntax, so this CANNOT be " + + "narrowed to RFC1918 — see the header comment in the resource.", + ) + } + + @Test + @DisplayName("the tunnel domain keeps its cleartext block, so it can never be downgraded") + fun tunnelDomainDeniesCleartext() { + val denying = elements(nsc(), "domain-config") + .filter { it.getAttribute("cleartextTrafficPermitted") == "false" } + assertTrue(denying.isNotEmpty(), "the tunnel domain-config that pins TLS was removed") + + val tunnelDomain = denying + .flatMap { cfg -> + val kids = cfg.getElementsByTagName("domain") + (0 until kids.length).map { kids.item(it) as Element } + } + .singleOrNull { it.textContent.trim() == "terminal.yaojia.wang" } + + assertTrue( + tunnelDomain != null, + "terminal.yaojia.wang must stay under a cleartextTrafficPermitted=false domain-config: " + + "it always serves a real LE cert and is mTLS-gated, so a plaintext dial there can only " + + "be a downgrade or a typo.", + ) + assertEquals( + "true", + tunnelDomain!!.getAttribute("includeSubdomains"), + "per-host tunnel subdomains (e.g. h7fd8.terminal.yaojia.wang) must be covered too", + ) + } + + @Test + @DisplayName("no user-installed CA can ever be trusted") + fun trustAnchorsAreSystemOnly() { + val doc = nsc() + assertTrue( + elements(doc, "debug-overrides").isEmpty(), + "a trust-anchor block would let an added device CA MITM a build", + ) + val certs = elements(doc, "certificates") + assertTrue(certs.isNotEmpty(), "trust anchors must stay declared, not inherited") + certs.forEach { + assertEquals( + "system", + it.getAttribute("src"), + "only system CAs may be trusted (plan §6.9); 'user' would trust an added device CA", + ) + } + } + + @Test + @DisplayName("the manifest attribute agrees with the config — they can never drift apart") + fun manifestAgreesWithConfig() { + val app = elements(parse("src/main/AndroidManifest.xml"), "application").single() + assertEquals( + "true", + app.getAttributeNS("http://schemas.android.com/apk/res/android", "usesCleartextTraffic"), + "the manifest must not state the opposite of what network_security_config ships. The " + + "config wins at runtime on minSdk 29, so a stale 'false' here is a lie to the reader, " + + "not a second layer of defence.", + ) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt index 525168b..ef9c7c6 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt @@ -204,6 +204,109 @@ class QuickReplyTest { assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = false)) } + // ── 4. The palette presenter (the production state holder behind the chips) ──── + + @Test + fun `palette load publishes the stored chips`() = runTest { + val palette = QuickReplyPalette(InMemoryQuickReplyStore()) + assertTrue(palette.chips.value.isEmpty(), "nothing is published before load()") + + palette.load() + assertEquals(QuickReplyDefaults.chips, palette.chips.value) + } + + @Test + fun `palette CRUD republishes the store's new list every time`() = runTest { + val palette = QuickReplyPalette(InMemoryQuickReplyStore(newId = sequentialId())) + palette.load() + + palette.add("run tests") + assertEquals("run tests", palette.chips.value.last().text) + + palette.edit("qr-yes", "yes please") + assertEquals("yes please", palette.chips.value.single { it.id == "qr-yes" }.text) + + val addedId = palette.chips.value.last().id + palette.remove(addedId) + assertFalse(palette.chips.value.any { it.id == addedId }) + + val idsBeforeMove = palette.chips.value.map { it.id } + palette.move(0, idsBeforeMove.lastIndex) + assertEquals(idsBeforeMove.drop(1) + idsBeforeMove.first(), palette.chips.value.map { it.id }) + } + + @Test + fun `palette rejects a blank add without touching the published list`() = runTest { + val palette = QuickReplyPalette(InMemoryQuickReplyStore()) + palette.load() + val before = palette.chips.value + + palette.add(" ") + assertEquals(before, palette.chips.value) + } + + @Test + fun `an emptied palette stays empty (no default re-seed) and hides the row`() = runTest { + val palette = QuickReplyPalette(InMemoryQuickReplyStore()) + palette.load() + palette.chips.value.forEach { palette.remove(it.id) } + + assertTrue(palette.chips.value.isEmpty()) + assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = palette.chips.value.isNotEmpty())) + } + + // ── 5. The editor's one-field select / commit reducer ───────────────────────── + + @Test + fun `a fresh editor state adds`() { + val state = QuickReplyEditorState() + assertNull(state.selectedId) + assertEquals("", state.draft) + assertEquals(QuickReplyCommit.NONE, state.commitKind()) // blank draft → nothing to commit + } + + @Test + fun `a non-blank draft with no selection is an ADD`() { + assertEquals(QuickReplyCommit.ADD, QuickReplyEditorState(draft = "deploy").commitKind()) + } + + @Test + fun `selecting a chip loads its text and switches the commit to EDIT`() { + val state = QuickReplyEditorState().selecting(QuickReplyChip("qr-yes", "yes")) + assertEquals("qr-yes", state.selectedId) + assertEquals("yes", state.draft) + assertEquals(QuickReplyCommit.EDIT, state.commitKind()) + } + + @Test + fun `a blank draft never commits, selected or not`() { + assertEquals(QuickReplyCommit.NONE, QuickReplyEditorState(selectedId = "qr-yes", draft = " ").commitKind()) + assertEquals(QuickReplyCommit.NONE, QuickReplyEditorState(draft = "").commitKind()) + } + + @Test + fun `clearing drops the selection and the draft, and never mutates the previous state`() { + val selected = QuickReplyEditorState().selecting(QuickReplyChip("qr-no", "no")) + val cleared = selected.cleared() + + assertNull(cleared.selectedId) + assertEquals("", cleared.draft) + // The previous snapshot is untouched (immutability). + assertEquals("qr-no", selected.selectedId) + assertEquals("no", selected.draft) + } + + @Test + fun `the draft is carried verbatim into the commit (surrounding whitespace preserved)`() = runTest { + val state = QuickReplyEditorState(draft = " spaced ") + assertEquals(QuickReplyCommit.ADD, state.commitKind()) + + val palette = QuickReplyPalette(InMemoryQuickReplyStore(initial = emptyList(), newId = sequentialId())) + palette.load() + palette.add(state.draft) + assertEquals(" spaced ", palette.chips.value.single().text) + } + // ── Pure transforms never mutate the receiver ───────────────────────────────── @Test diff --git a/android/app/src/test/java/wang/yaojia/webterm/nav/SessionRowMenuTest.kt b/android/app/src/test/java/wang/yaojia/webterm/nav/SessionRowMenuTest.kt new file mode 100644 index 0000000..15c9002 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/nav/SessionRowMenuTest.kt @@ -0,0 +1,89 @@ +package wang.yaojia.webterm.nav + +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.api.models.LiveSessionInfo +import wang.yaojia.webterm.components.sessionRowMenuActions +import wang.yaojia.webterm.designsystem.DisplayStatus +import wang.yaojia.webterm.viewmodels.SessionRow +import java.util.UUID + +/** + * The pointer-context-menu ACTION SET for a session row (A26 — the large-screen right-click menu). + * + * The menu's *visibility* gate is [PointerMenuPolicy] (already covered by `LayoutPolicyTest`); this covers + * the other half — which entries a row offers and what each one does. Pure JVM: the entries are built by + * [sessionRowMenuActions], so the wiring is verified without a device (the pointer gesture itself is + * device-QA, plan §7). + * + * NOTE the "copy" entry iOS has is deliberately ABSENT: copy-out belongs to the terminal's own text + * selection `ActionMode`, and a list row has no selected text — so the menu must not advertise it. + * + * (This file sits in the `nav` test package because it covers the row-menu half of the adaptive shell + * wired here; the function itself lives beside [wang.yaojia.webterm.components.PointerContextMenu] and + * [wang.yaojia.webterm.components.ContextMenuAction], which is its cohesive home.) + */ +class SessionRowMenuTest { + + private fun row(cwd: String?): SessionRow = SessionRow( + info = LiveSessionInfo( + id = UUID.fromString("11111111-2222-4333-8444-555555555555"), + createdAt = 0L, + clientCount = 1, + exited = false, + cwd = cwd, + cols = 80, + rows = 24, + lastOutputAt = 1L, + ), + displayStatus = DisplayStatus.Working, + title = "web-terminal", + isUnread = false, + ) + + @Test + fun `a row with a cwd offers open, new-session-in-cwd and kill — and never copy`() { + val actions = sessionRowMenuActions(row("/srv/app"), onOpen = {}, onNewSessionInCwd = {}, onKill = {}) + + assertEquals(3, actions.size) + assertTrue(actions[0].label.contains("打开"), "the first entry opens the session: ${actions[0].label}") + assertTrue(actions[1].label.contains("开新会话"), "cwd spawn entry missing: ${actions[1].label}") + assertTrue(actions[2].label.contains("终止"), "kill entry missing: ${actions[2].label}") + assertFalse(actions.any { it.label.contains("复制") }, "copy is a documented gap — must not appear") + } + + @Test + fun `a row without a usable cwd drops the new-session entry`() { + assertEquals(2, sessionRowMenuActions(row(null), onOpen = {}, onNewSessionInCwd = {}, onKill = {}).size) + assertEquals(2, sessionRowMenuActions(row(" "), onOpen = {}, onNewSessionInCwd = {}, onKill = {}).size) + } + + @Test + fun `no spawn handler wired means no new-session entry, even with a cwd`() { + val actions = sessionRowMenuActions(row("/srv/app"), onOpen = {}, onNewSessionInCwd = null, onKill = {}) + assertEquals(2, actions.size) + assertFalse(actions.any { it.label.contains("开新会话") }) + } + + @Test + fun `each entry invokes its callback with the row's own id and cwd`() { + var opened: UUID? = null + var spawnedIn: String? = null + var killed: UUID? = null + val target = row("/home/dev/project") + + val actions = sessionRowMenuActions( + row = target, + onOpen = { opened = it }, + onNewSessionInCwd = { spawnedIn = it }, + onKill = { killed = it }, + ) + actions.forEach { it.onClick() } + + assertEquals(target.id, opened) + assertEquals("/home/dev/project", spawnedIn) + assertEquals(target.id, killed) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/screens/TerminalReclaimPolicyTest.kt b/android/app/src/test/java/wang/yaojia/webterm/screens/TerminalReclaimPolicyTest.kt new file mode 100644 index 0000000..46f808a --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/screens/TerminalReclaimPolicyTest.kt @@ -0,0 +1,67 @@ +package wang.yaojia.webterm.screens + +import androidx.lifecycle.Lifecycle +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.components.BannerModel +import wang.yaojia.webterm.session.FailureReason +import kotlin.time.Duration.Companion.seconds + +/** + * The §6.4 latest-writer-wins reclaim policy — the pure half of the device-switch resize path + * (plan §6.4 / A21). The Compose/lifecycle plumbing that feeds it is device-QA (§7); WHICH events + * must reach `SessionEngine.notifyForegrounded` is decided here, in JVM-testable data. + * + * The rule that makes this non-obvious: `:terminal-view`'s `TerminalResizeDriver` now owns pushing + * the measured grid on a layout change (and dedups sub-cell churn), so an extra engine-level + * re-assert on the SAME size-changed event would double every rotation's `resize` frame. The engine + * call therefore belongs to the events the view layer cannot see — becoming the active device again + * (resume / window-focus gain) — plus the disconnected case, where it is the connect-now nudge. + */ +class TerminalReclaimPolicyTest { + + // ── Lifecycle: only ON_RESUME means "this device is active again" ──────────────────────────── + + @Test + fun `ON_RESUME reclaims the shared PTY size`() { + assertTrue(TerminalReclaimPolicy.reclaimsOnLifecycle(Lifecycle.Event.ON_RESUME)) + } + + @Test + fun `no other lifecycle event reclaims`() { + Lifecycle.Event.values() + .filter { it != Lifecycle.Event.ON_RESUME } + .forEach { event -> + assertFalse(TerminalReclaimPolicy.reclaimsOnLifecycle(event), "$event must not reclaim") + } + } + + // ── Window focus: gain only (a blur must never resize — plan §6.4 removed the size-vote) ──── + + @Test + fun `window-focus GAIN reclaims and a blur does not`() { + assertTrue(TerminalReclaimPolicy.reclaimsOnWindowFocus(focused = true)) + assertFalse(TerminalReclaimPolicy.reclaimsOnWindowFocus(focused = false)) + } + + // ── View size change: nudge only while disconnected (the driver owns the connected case) ──── + + @Test + fun `a size change while connected does NOT re-assert (the view driver already pushed it)`() { + assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Hidden)) + } + + @Test + fun `a size change while connecting or reconnecting nudges connect-now`() { + assertTrue(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Connecting)) + assertTrue(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Reconnecting(2, 4.seconds))) + } + + @Test + fun `a size change on a terminal banner never nudges (no reconnect is owed)`() { + assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE))) + assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Exited(0, null))) + assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Exited(-1, "spawn failed"))) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt index 85716bf..5afb2d3 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt @@ -1,6 +1,7 @@ package wang.yaojia.webterm.viewmodels import wang.yaojia.webterm.api.models.CreateWorktreeResult +import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.GitLogResult import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PrStatus @@ -9,6 +10,7 @@ import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.PruneWorktreesResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult import wang.yaojia.webterm.api.models.UiPrefs +import wang.yaojia.webterm.api.models.WorktreeState /** * A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel, @@ -18,6 +20,8 @@ import wang.yaojia.webterm.api.models.UiPrefs */ class FakeWorktreeGateway( private val detail: ProjectDetail? = null, + private val worktreeStates: Map = emptyMap(), + private val fetchOutcome: GitWriteOutcome = GitWriteOutcome.Ok(FetchResult()), private val prResult: PrStatus? = null, private val prThrows: Boolean = false, private val logResult: GitLogResult? = null, @@ -29,6 +33,8 @@ class FakeWorktreeGateway( val createCalls = mutableListOf>() val removeCalls = mutableListOf>() val pruneCalls = mutableListOf() + val worktreeStateCalls = mutableListOf() + val fetchCalls = mutableListOf() var detailCalls = 0 private set @@ -51,6 +57,16 @@ class FakeWorktreeGateway( return logResult ?: throw NotImplementedError("no log configured") } + override suspend fun worktreeState(worktreePath: String): WorktreeState { + worktreeStateCalls += worktreePath + return worktreeStates[worktreePath] ?: throw RuntimeException("probe failed for $worktreePath") + } + + override suspend fun gitFetch(path: String): GitWriteOutcome { + fetchCalls += path + return fetchOutcome + } + override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome { createCalls += Triple(path, branch, base) return createOutcome diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateAutoModeTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateAutoModeTest.kt new file mode 100644 index 0000000..2453db6 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateAutoModeTest.kt @@ -0,0 +1,184 @@ +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.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.session.Adopted +import wang.yaojia.webterm.session.Exited +import wang.yaojia.webterm.session.Gate +import wang.yaojia.webterm.session.GateState +import wang.yaojia.webterm.session.Queued +import wang.yaojia.webterm.session.SessionEvent +import wang.yaojia.webterm.wire.ApproveMode +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.GateKind +import wang.yaojia.webterm.wiring.TerminalSessionController + +/** + * [GateViewModel] — the `GET /config/ui` `allowAutoMode` gate plus the two cockpit signals the VM used + * to DROP on the floor (`queue` depth and the adopted session id). + * + * The `allowAutoMode` gate is security-relevant: `approve.mode="acceptEdits"` puts Claude into + * auto-accept-edits, and an operator who set `ALLOW_AUTO_MODE=0` on the host must not be overridden from + * a phone. The gate **fails CLOSED** — auto is refused until a successful fetch says otherwise — because + * the permissive default is the dangerous one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GateAutoModeTest { + + private class FakeController : TerminalSessionController { + private val channel = Channel(Channel.UNLIMITED) + val decisions = mutableListOf>() + + override fun controlEvents(): Flow = channel.receiveAsFlow() + override val output: Flow = 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) + } + } + + private fun planGate(epoch: Int) = Gate(GateState(kind = GateKind.PLAN, detail = "plan", epoch = epoch)) + + // ── allowAutoMode fails CLOSED ─────────────────────────────────────────────────────────────── + + @Test + fun `auto-accept is refused until the host says it is allowed`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + fake.emit(planGate(1)) + + assertFalse(vm.uiState.value.allowAutoMode) // fail-closed default + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1) + + assertTrue(fake.decisions.isEmpty()) // NOTHING was sent + } + + @Test + fun `the other two plan affordances still work while auto is disabled`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + fake.emit(planGate(1)) + + vm.decide(GateDecision.APPROVE, epoch = 1) + vm.decide(GateDecision.REJECT, epoch = 1) + + assertEquals(2, fake.decisions.size) + assertEquals( + ClientMessage.Approve(mode = ApproveMode.DEFAULT), + fake.decisions[0].second, + ) + assertEquals(ClientMessage.Reject, fake.decisions[1].second) + } + + @Test + fun `auto-accept is sent once the host allows it`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + vm.setAutoModeAllowed(true) + fake.emit(planGate(7)) + + assertTrue(vm.uiState.value.allowAutoMode) + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 7) + + assertEquals( + listOf(7 to ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS)), + fake.decisions.toList(), + ) + } + + @Test + fun `revoking auto mode drops a later auto decision`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + vm.setAutoModeAllowed(true) + vm.setAutoModeAllowed(false) + fake.emit(planGate(3)) + + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 3) + + assertTrue(fake.decisions.isEmpty()) + } + + @Test + fun `the epoch stale-guard still outranks an allowed auto decision`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + vm.setAutoModeAllowed(true) + fake.emit(planGate(2)) + + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1) // the gate on screen was epoch 1 + + assertTrue(fake.decisions.isEmpty()) + } + + // ── The `queue` frame (previously dropped) ─────────────────────────────────────────────────── + + @Test + fun `the queue frame publishes the pending depth`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + assertNull(vm.uiState.value.queueDepth) // unknown before the first frame + + fake.emit(Queued(3)) + assertEquals(3, vm.uiState.value.queueDepth) + + fake.emit(Queued(0)) + assertEquals(0, vm.uiState.value.queueDepth) + } + + // ── The adopted session id (the queue routes need it) ──────────────────────────────────────── + + @Test + fun `the adopted session id is published so a fresh spawn can be queued to`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + assertNull(vm.uiState.value.sessionId) + + fake.emit(Adopted("11111111-2222-4333-8444-555555555555")) + assertEquals("11111111-2222-4333-8444-555555555555", vm.uiState.value.sessionId) + } + + @Test + fun `an exit clears the queue depth along with the gate`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + fake.emit(planGate(1)) + fake.emit(Queued(2)) + + fake.emit(Exited(code = 0)) + + assertNull(vm.uiState.value.gate) + assertNull(vm.uiState.value.queueDepth) // an exited session has no queue to show + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GatePreviewTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GatePreviewTest.kt new file mode 100644 index 0000000..d9f7e7e --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GatePreviewTest.kt @@ -0,0 +1,192 @@ +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.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.assertNotNull +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.Gate +import wang.yaojia.webterm.session.GateState +import wang.yaojia.webterm.session.SessionEvent +import wang.yaojia.webterm.wire.ApprovalPreview +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.GateKind +import wang.yaojia.webterm.wire.PreviewDiffFile +import wang.yaojia.webterm.wire.PreviewDiffHunk +import wang.yaojia.webterm.wire.PreviewDiffLine +import wang.yaojia.webterm.wiring.TerminalSessionController + +/** + * W1 approval preview — a remote one-tap approval must show WHAT it approves (the command or the + * diff), not just a tool name. The content is derived server-side from the hook `tool_input`, i.e. it + * is **attacker-influenced**: it renders strictly inert, and this test pins the client-side + * defence-in-depth sanitizer plus the rule that an ABSENT preview is a NORMAL state (the name-only + * gate stays fully decidable — a missing preview never blocks or breaks a gate). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GatePreviewTest { + + private class FakeController : TerminalSessionController { + private val channel = Channel(Channel.UNLIMITED) + val decisions = mutableListOf>() + override fun controlEvents(): Flow = channel.receiveAsFlow() + override val output: Flow = 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) } + } + + // ── Absent preview is a NORMAL state ───────────────────────────────────────────────────────── + + @Test + fun `a gate with no preview stays fully decidable and shows no preview block`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit(Gate(GateState(kind = GateKind.TOOL, detail = "Bash", epoch = 1))) + + assertNotNull(vm.uiState.value.gate, "the gate itself is unaffected") + assertNull(vm.uiState.value.preview, "no preview → the name-only bar, not an error") + vm.decide(GateDecision.APPROVE, 1) + assertEquals(1, fake.decisions.size, "a preview-less gate is still approvable") + } + + @Test + fun `a held gate carries its preview onto the ui state`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit( + Gate( + GateState( + kind = GateKind.TOOL, + detail = "Bash", + epoch = 1, + preview = ApprovalPreview.Command("rm -rf build/"), + ), + ), + ) + + val preview = vm.uiState.value.preview + assertTrue(preview is ApprovalPreview.Command, "expected a command preview, got $preview") + assertEquals("rm -rf build/", (preview as ApprovalPreview.Command).text) + } + + @Test + fun `a lifted gate clears any preview with it`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit( + Gate( + GateState( + kind = GateKind.TOOL, + detail = "Bash", + epoch = 1, + preview = ApprovalPreview.Command("rm -rf build/"), + ), + ), + ) + assertNotNull(vm.uiState.value.preview) + fake.emit(Gate(null)) + + assertNull(vm.uiState.value.gate) + assertNull(vm.uiState.value.preview, "a stale preview must never outlive its gate") + } + + @Test + fun `a new gate replaces the previous gate's preview`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit(Gate(gateWithCommand(epoch = 1))) + fake.emit(Gate(gateWithCommand(epoch = 2))) + + assertEquals("cmd for epoch 2", (vm.uiState.value.preview as ApprovalPreview.Command).text) + } + + private fun gateWithCommand(epoch: Int) = GateState( + kind = GateKind.TOOL, + detail = "Bash", + epoch = epoch, + preview = ApprovalPreview.Command("cmd for epoch $epoch"), + ) + + // ── Inert rendering (§8) ──────────────────────────────────────────────────────────────────── + + @Test + fun `command preview text keeps newlines and strips control and ANSI bytes`() { + val raw = "rm -rf build/\n\u001B[31mecho\u0007 boom\u0000" + + val safe = inertPreviewText(raw) + + assertTrue(safe.contains("\n"), "a shell command's line structure is meaningful") + assertFalse(safe.contains("\u001B"), "no ESC \u2014 preview text must never drive the terminal") + assertFalse(safe.contains("\u0007"), "no BEL") + assertFalse(safe.contains("\u0000"), "no NUL") + assertEquals("rm -rf build/\n[31mecho boom", safe) + } + + @Test + fun `command preview leaves ordinary text and markup characters literal`() { + val raw = "grep -n '' *.ts && echo \"a&b\"" + + assertEquals(raw, inertPreviewText(raw), "no escaping, no linkify — the UI renders it as text") + } + + @Test + fun `tabs survive but carriage returns do not`() { + assertEquals("a\tb", inertPreviewText("a\tb")) + assertEquals("ab", inertPreviewText("a\rb"), "a bare CR would rewrite the rendered line") + } + + @Test + fun `the display text of a command preview is the sanitized one`() { + val preview = ApprovalPreview.Command("echo \u001Bx", truncated = true) + + assertEquals("echo x", preview.inertText(), "what the UI paints") + assertTrue(preview.truncated) + } + + @Test + fun `a command preview with nothing legible left is not a preview at all`() { + assertNull(ApprovalPreview.Command(" \u0000 ").inertText()) + assertEquals("ls", ApprovalPreview.Command("ls").inertText()) + } + + @Test + fun `a diff preview carries its one synthetic file and truncation flag`() { + val file = PreviewDiffFile( + oldPath = "a.ts", + newPath = "a.ts", + status = "modified", + added = 1, + removed = 0, + binary = false, + hunks = listOf(PreviewDiffHunk("@@ -1 +1 @@", listOf(PreviewDiffLine("added", "+x")))), + ) + + val preview = ApprovalPreview.Diff(file, truncated = true) + + assertEquals("a.ts", preview.file.newPath) + assertEquals("+x", preview.file.hunks.single().lines.single().text) + assertTrue(preview.truncated) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt index c5ed22b..91629b8 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt @@ -141,6 +141,9 @@ class GateViewModelTest { val fake = FakeController() val vm = GateViewModel(fake) vm.bind(backgroundScope) + // `acceptEdits` is gated by the host's `GET /config/ui` `allowAutoMode`, which fails CLOSED — so a + // test about the WIRE MAPPING has to opt in explicitly. The gate itself is `GateAutoModeTest`. + vm.setAutoModeAllowed(true) fake.emit(planGate(3)) vm.decide(GateDecision.APPROVE, epoch = 3) // default review diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GitSyncPanelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GitSyncPanelTest.kt new file mode 100644 index 0000000..e22498b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GitSyncPanelTest.kt @@ -0,0 +1,275 @@ +package wang.yaojia.webterm.viewmodels + +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.CommitLogEntry +import wang.yaojia.webterm.api.models.GitLogResult +import wang.yaojia.webterm.api.models.SyncState +import wang.yaojia.webterm.api.models.WorktreeInfo + +/** + * w6 git panel — the pure presentation core (`syncBandOf` / `boundaryIndex` / `worktreeChips` / + * `headerDirtyChip`), ported from the shipped web behaviour in `public/projects.ts` + + * `public/git-log.ts` and the design in `docs/mockups/project-detail-git.html`. + * + * The load-bearing rule these tests exist for: **exactly one state may read as the green + * "all clear"** — `ahead == 0 && behind == 0` AND a fetch inside SyncState.FETCH_FRESH_WINDOW_MS. "No upstream" + * leaves ahead/behind undefined and MUST fall through to an explicit `no upstream`, never to green + * (docs/plans/w6-project-git-panel.md: "the single easiest bug to ship"). + */ +class GitSyncPanelTest { + + private val now = 1_800_000_000_000L + private val fresh = now - 60_000L // 1 min ago → inside the 1 h staleness window + private val ancient = now - (19L * 24 * 60 * 60 * 1000) // 19 days ago + + // ── The green path: exactly one state ──────────────────────────────────────────────────────── + + @Test + fun `ahead zero behind zero with a fresh fetch is the one green all-clear`() { + val band = syncBandOf( + SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = fresh), + nowMs = now, + ) + + val tracking = assertTracking(band) + assertFalse(tracking.isStale, "a 1-minute-old fetch is fresh") + assertTrue(tracking.isAllClear, "↑0 ↓0 + fresh fetch is the single green state") + } + + @Test + fun `ahead zero behind zero with a stale fetch is never green and marks the behind number`() { + val band = syncBandOf( + SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = ancient), + nowMs = now, + ) + + val tracking = assertTracking(band) + assertTrue(tracking.isStale, "a 19-day-old FETCH_HEAD is stale") + assertFalse(tracking.isAllClear, "a stale ↓0 is a guess, not a fact — never green") + } + + @Test + fun `never fetched is stale, not fresh`() { + val band = syncBandOf( + SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = null), + nowMs = now, + ) + + val tracking = assertTracking(band) + assertTrue(tracking.isStale) + assertFalse(tracking.isAllClear) + } + + /** THE fall-through this feature is most likely to get wrong (plan §"Design rule"). */ + @Test + fun `no upstream renders an explicit no-upstream state and never the green path`() { + val band = syncBandOf( + SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh), + nowMs = now, + ) + + assertEquals(SyncBand.NoUpstream, band, "a branch that tracks nothing has no ↑↓ to report") + assertFalse(band!!.isAllClear, "absent numbers must never read as 'in sync'") + } + + @Test + fun `a blank upstream string is treated as no upstream`() { + val band = syncBandOf(SyncState(upstream = " ", lastFetchMs = fresh), nowMs = now) + + assertEquals(SyncBand.NoUpstream, band) + } + + @Test + fun `only the tracking-in-sync state is ever all clear`() { + val allClear = listOf( + syncBandOf(SyncState(detached = true), nowMs = now), + syncBandOf(SyncState(upstream = null), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 1, behind = 0, lastFetchMs = fresh), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 3, lastFetchMs = fresh), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = ancient), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = fresh), nowMs = now), + ).map { it?.isAllClear } + + assertEquals(listOf(false, false, false, false, false, true), allClear) + } + + // ── Degraded shapes ───────────────────────────────────────────────────────────────────────── + + @Test + fun `a detached HEAD shows the short sha, no branch, and disables fetch`() { + val band = syncBandOf(SyncState(detached = true, lastFetchMs = fresh), head = "1dbed54", nowMs = now) + + assertEquals(SyncBand.Detached("1dbed54"), band) + assertFalse(band!!.canFetch, "fetch is not available on a detached HEAD") + } + + @Test + fun `fetch stays available for tracking and no-upstream states`() { + assertTrue(syncBandOf(SyncState(upstream = null), nowMs = now)!!.canFetch) + assertTrue(syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now)!!.canFetch) + } + + @Test + fun `absent sync facts render no band at all (non-git project)`() { + assertNull(syncBandOf(null, nowMs = now)) + } + + @Test + fun `absent ahead and behind on a tracking branch count as zero`() { + val tracking = assertTracking( + syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now), + ) + + assertEquals(0, tracking.ahead) + assertEquals(0, tracking.behind) + assertTrue(tracking.isAllClear, "a tracking branch with no counts and a fresh fetch IS in sync") + } + + // ── Header dirty chip (● 3, not a bare dot) ───────────────────────────────────────────────── + + @Test + fun `a dirty count renders the number, not a bare dot`() { + assertEquals(GitPanelCopy.dirtyChip(3), headerDirtyChip(dirtyCount = 3, dirty = true)) + assertTrue(headerDirtyChip(dirtyCount = 3, dirty = true)!!.contains("3")) + } + + @Test + fun `dirty without a count falls back to the countless chip, and clean renders nothing`() { + assertEquals(GitPanelCopy.DIRTY_NO_COUNT, headerDirtyChip(dirtyCount = null, dirty = true)) + assertNull(headerDirtyChip(dirtyCount = 0, dirty = true), "0 changed files is not dirty") + assertNull(headerDirtyChip(dirtyCount = null, dirty = false)) + assertNull(headerDirtyChip(dirtyCount = null, dirty = null)) + } + + // ── Commit list: unpushed marking + ONE boundary ───────────────────────────────────────────── + + private fun log(vararg unpushed: Boolean, upstream: String? = null, truncated: Boolean = false) = + GitLogResult( + commits = unpushed.mapIndexed { i, flag -> + CommitLogEntry(hash = "hash$i", at = now - i * 1000L, subject = "s$i", unpushed = flag) + }, + truncated = truncated, + upstream = upstream, + ) + + @Test + fun `the boundary is drawn once, after the last unpushed commit`() { + val log = log(true, true, false, false, upstream = "origin/develop") + + assertEquals(1, log.boundaryIndex(), "one boundary, right after the last unpushed row") + assertEquals("origin/develop", log.boundaryLabel()) + assertEquals(2, log.unpushedCount) + } + + /** + * A merge of an older branch interleaves an unpushed commit BELOW a pushed one. The per-commit flag + * is authoritative — never the row index — and the single boundary sits after the LAST unpushed row + * (plan §TDD step 19). + */ + @Test + fun `an unpushed commit sorting below a pushed one still moves the boundary`() { + val log = log(true, false, true, false, upstream = "origin/develop") + + assertEquals(2, log.boundaryIndex()) + assertEquals(listOf(true, false, true, false), log.commits.map { it.isUnpushed() }) + } + + @Test + fun `no unpushed commits means no boundary`() { + val log = log(false, false, upstream = "origin/develop") + + assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex()) + assertEquals(0, log.unpushedCount) + } + + @Test + fun `without an upstream no boundary is drawn even when rows are marked`() { + val log = log(true, true, upstream = null) + + assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex(), "nothing to draw the boundary against") + assertNull(log.boundaryLabel()) + } + + @Test + fun `a blank upstream draws no boundary and no label`() { + val log = log(true, upstream = " ") + + assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex()) + assertNull(log.boundaryLabel(), "an empty label would claim a position it cannot name") + } + + @Test + fun `an absent unpushed flag reads as pushed, never the other way round`() { + val commit = CommitLogEntry(hash = "h", at = now, subject = "s", unpushed = null) + + assertFalse(commit.isUnpushed(), "unknown must not be marked; only an explicit true counts") + } + + // ── Worktree section ──────────────────────────────────────────────────────────────────────── + + @Test + fun `the worktree section always reads Worktrees with its count, even at one`() { + assertEquals("Worktrees (1)", GitPanelCopy.worktreesTitle(1)) + assertEquals("Worktrees (2)", GitPanelCopy.worktreesTitle(2)) + assertEquals("Worktrees (0)", GitPanelCopy.worktreesTitle(0)) + } + + private val mainWorktree = WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true) + + @Test + fun `a worktree with no upstream shows no-upstream and never a green chip`() { + val chips = worktreeChips( + WorktreeInfo(path = "/repo/.claude/worktrees/x", branch = "worktree-x"), + WorktreeRowState(sync = SyncState(upstream = null, lastFetchMs = fresh)), + ) + + assertTrue(chips.any { it.label == GitPanelCopy.NO_UPSTREAM_CHIP }) + assertFalse(chips.any { it.tone == GitChipTone.OK }, "a worktree row has no green all-clear chip") + } + + @Test + fun `worktree chips report main current locked prunable then sync then dirty`() { + val chips = worktreeChips( + mainWorktree.copy(locked = true, prunable = true), + WorktreeRowState(sync = SyncState(upstream = "origin/develop", ahead = 9, lastFetchMs = fresh), dirtyCount = 3), + ) + + assertEquals( + listOf( + GitPanelCopy.MAIN_CHIP, + GitPanelCopy.CURRENT_CHIP, + GitPanelCopy.LOCKED_CHIP, + GitPanelCopy.PRUNABLE_CHIP, + GitPanelCopy.aheadChip(9), + GitPanelCopy.dirtyCountChip(3), + ), + chips.map { it.label }, + ) + } + + @Test + fun `an unprobed worktree row shows no sync chip at all rather than a guess`() { + val chips = worktreeChips(mainWorktree, WorktreeRowState()) + + assertEquals(listOf(GitPanelCopy.MAIN_CHIP, GitPanelCopy.CURRENT_CHIP), chips.map { it.label }) + } + + @Test + fun `a detached worktree row says detached`() { + val chips = worktreeChips( + WorktreeInfo(path = "/repo/wt", branch = null, head = "1dbed54"), + WorktreeRowState(sync = SyncState(detached = true)), + ) + + assertEquals(listOf(GitPanelCopy.DETACHED_CHIP_SHORT), chips.map { it.label }) + } + + private fun assertTracking(band: SyncBand?): SyncBand.Tracking { + assertTrue(band is SyncBand.Tracking, "expected a tracking band, got $band") + return band as SyncBand.Tracking + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailFetchTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailFetchTest.kt new file mode 100644 index 0000000..c449946 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailFetchTest.kt @@ -0,0 +1,177 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +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.api.models.FetchResult +import wang.yaojia.webterm.api.models.GitWriteOutcome +import wang.yaojia.webterm.api.models.GitLogResult +import wang.yaojia.webterm.api.models.PrAvailability +import wang.yaojia.webterm.api.models.PrStatus +import wang.yaojia.webterm.api.models.ProjectDetail +import wang.yaojia.webterm.api.models.WorktreeInfo +import wang.yaojia.webterm.api.models.WorktreeState + +/** + * w6/G2+G3 — the Fetch action on the project-detail sync band. + * + * `git fetch` only moves remote-tracking refs (no pull, no merge), so it is the ONE write the panel + * offers. The rules pinned here: one in-flight fetch at a time (a second tap is dropped, never + * queued), a success re-loads the detail so `behind`/`lastFetchMs` actually move, and a FAILURE + * changes nothing — `lastFetchMs` stays where it was so the `stale` flag stays ON (never silently + * mark the data fresh). + */ +class ProjectDetailFetchTest { + + private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "develop") + + private fun okFetch(lastFetchMs: Long?): GitWriteOutcome = + GitWriteOutcome.Ok(FetchResult(lastFetchMs)) + + private fun vmWith( + fetchRemote: (suspend () -> GitWriteOutcome)?, + ): Pair { + val detailCalls = intArrayOf(0) + val vm = ProjectDetailViewModel( + path = "/repo", + fetch = { detailCalls[0]++; detail }, + fetchRemote = fetchRemote, + ) + return vm to detailCalls + } + + @Test + fun `no fetch seam wired means the button is not offered`() { + val (vm, _) = vmWith(null) + + assertFalse(vm.canFetch, "without a fetch gateway the band must not offer the action") + assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value) + } + + @Test + fun `a successful fetch reports the new lastFetchMs and re-loads the detail`() = runTest { + val (vm, detailCalls) = vmWith { okFetch(42L) } + vm.load() + val callsAfterLoad = detailCalls[0] + + vm.fetchUpstream() + + assertTrue(vm.canFetch) + assertEquals(ProjectDetailViewModel.FetchPhase.Done(42L), vm.fetchPhase.value) + assertEquals(callsAfterLoad + 1, detailCalls[0], "a fetch refreshes ahead/behind") + } + + @Test + fun `a failing fetch surfaces a short message and does NOT re-load or mark data fresh`() = runTest { + val (vm, detailCalls) = vmWith { throw RuntimeException("offline") } + vm.load() + val callsAfterLoad = detailCalls[0] + + vm.fetchUpstream() + + val phase = vm.fetchPhase.value + assertTrue(phase is ProjectDetailViewModel.FetchPhase.Failed, "expected Failed, got $phase") + assertEquals(GitPanelCopy.FETCH_FAILED, (phase as ProjectDetailViewModel.FetchPhase.Failed).message) + assertEquals(callsAfterLoad, detailCalls[0], "a failed fetch must not claim fresh data") + } + + @Test + fun `a second fetch while one is in flight is dropped, not queued`() = runTest { + val gate = CompletableDeferred>() + var invocations = 0 + val vm = ProjectDetailViewModel( + path = "/repo", + fetch = { detail }, + fetchRemote = { invocations++; gate.await() }, + ) + + val first = launch { vm.fetchUpstream() } + // Let the first call reach the suspension point, then tap again. + kotlinx.coroutines.yield() + assertEquals(ProjectDetailViewModel.FetchPhase.Working, vm.fetchPhase.value) + vm.fetchUpstream() + gate.complete(okFetch(7L)) + first.join() + + assertEquals(1, invocations, "the in-flight fetch is not re-entered") + assertEquals(ProjectDetailViewModel.FetchPhase.Done(7L), vm.fetchPhase.value) + } + + @Test + fun `the fetch banner is dismissable back to idle`() = runTest { + val (vm, _) = vmWith { okFetch(1L) } + vm.fetchUpstream() + + vm.clearFetchBanner() + + assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value) + } + + @Test + fun `a rejected fetch surfaces the server's own safe message verbatim`() = runTest { + val (vm, detailCalls) = vmWith { GitWriteOutcome.Rejected(status = 400, message = "两个 remote,无法确定目标") } + vm.load() + val callsAfterLoad = detailCalls[0] + + vm.fetchUpstream() + + assertEquals( + ProjectDetailViewModel.FetchPhase.Failed("两个 remote,无法确定目标"), + vm.fetchPhase.value, + ) + assertEquals(callsAfterLoad, detailCalls[0], "a rejected fetch must not claim fresh data") + } + + @Test + fun `a rate-limited fetch says so and does not retry`() = runTest { + val (vm, _) = vmWith { GitWriteOutcome.RateLimited } + + vm.fetchUpstream() + + assertEquals( + ProjectDetailViewModel.FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED), + vm.fetchPhase.value, + ) + } + + @Test + fun `a fetch failure never fails the detail load`() = runTest { + val (vm, _) = vmWith { throw RuntimeException("offline") } + + vm.load() + vm.fetchUpstream() + + assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "isolation: detail stays loaded") + } + + // ── w6/G7: the per-worktree probe ──────────────────────────────────────────────────────────── + + @Test + fun `probing skips the current worktree and isolates a failing row`() = runTest { + val worktrees = listOf( + WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true), + WorktreeInfo(path = "/repo/wt-a", branch = "worktree-a"), + WorktreeInfo(path = "/repo/wt-b", branch = "worktree-b"), + ) + val gateway = FakeWorktreeGateway( + detail = detail.copy(worktrees = worktrees), + prResult = PrStatus(availability = PrAvailability.NO_PR), + logResult = GitLogResult(), + // wt-b is missing on purpose: its probe throws, and must cost only its own row. + worktreeStates = mapOf("/repo/wt-a" to WorktreeState(path = "/repo/wt-a", dirtyCount = 2)), + ) + val vm = ProjectDetailViewModel.forGateway(gateway, "/repo") + vm.load() + + vm.probeWorktrees() + + assertEquals(listOf("/repo/wt-a", "/repo/wt-b"), gateway.worktreeStateCalls, "the current row is free") + assertEquals(setOf("/repo/wt-a"), vm.worktreeStates.value.keys) + assertEquals(2, vm.worktreeStates.value.getValue("/repo/wt-a").dirtyCount) + assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "a failed probe never fails the panel") + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt index 67d7f90..0f43bf8 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt @@ -204,6 +204,8 @@ class ProjectsViewModelTest { override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError() override suspend fun projectPr(path: String) = throw NotImplementedError() override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError() + override suspend fun worktreeState(worktreePath: String) = throw NotImplementedError() + override suspend fun gitFetch(path: String) = throw NotImplementedError() override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError() override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError() override suspend fun pruneWorktrees(path: String) = throw NotImplementedError() diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/QueueViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/QueueViewModelTest.kt new file mode 100644 index 0000000..1bfcb4f --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/QueueViewModelTest.kt @@ -0,0 +1,245 @@ +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.QueueSnapshot +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.api.routes.ApiClientError +import java.util.UUID + +/** + * [QueueViewModel] (W2 follow-up queue) — the write/read side of `POST|GET|DELETE + * /live-sessions/:id/queue`. + * + * The load-bearing properties asserted here are the ones a screenshot cannot show: + * - the prompt reaches the gateway **VERBATIM** (no trim, no normalisation, no client-side `\r`) and + * `appendEnter` stays a FLAG, so the SERVER materialises the carriage return (invariant #9); + * - every [QueueWriteOutcome] maps to its own UI notice, and a **429 is never auto-retried**; + * - a 503 permanently hides the affordance for the host (`isSupported = false`); + * - the authoritative depth comes from the server (`Ok(length)` / the `queue` WS frame), never from a + * local increment. + * The panel's Compose presentation is device-QA (plan §7). + */ +class QueueViewModelTest { + + private companion object { + val SESSION: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + } + + /** Records every call and replays scripted outcomes in order (the last one repeats). */ + private class FakeGateway( + private val snapshots: List = listOf(QueueSnapshot()), + private val writes: List = listOf(QueueWriteOutcome.Ok(1)), + ) : QueueGateway { + val enqueued = mutableListOf>() + val cleared = mutableListOf() + var snapshotCalls: Int = 0 + + override suspend fun snapshot(id: UUID): QueueSnapshot { + val next = snapshots[minOf(snapshotCalls, snapshots.lastIndex)] + snapshotCalls += 1 + if (next is Throwable) throw next + return next as QueueSnapshot + } + + override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome { + enqueued += Triple(id, text, appendEnter) + return writes[minOf(enqueued.size - 1, writes.lastIndex)] + } + + override suspend fun clear(id: UUID): QueueWriteOutcome { + cleared += id + return writes[minOf(cleared.size - 1, writes.lastIndex)] + } + } + + // ── Verbatim discipline (invariant #9) ─────────────────────────────────────────────────────── + + @Test + fun `the prompt reaches the gateway byte-for-byte with appendEnter as a flag`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Ok(2))) + val vm = QueueViewModel(gateway) + + val raw = " run the tests\nthen commit " + vm.enqueue(SESSION, raw) + + assertEquals(1, gateway.enqueued.size) + val (id, text, appendEnter) = gateway.enqueued.single() + assertEquals(SESSION, id) + assertEquals(raw, text) // no trim, no normalisation + assertFalse(text.contains('\r')) // the CR is the SERVER's job + assertTrue(appendEnter) + assertEquals(2, vm.uiState.value.depth) // authoritative depth from Ok(length) + } + + @Test + fun `an empty prompt is never sent`() = runTest { + val gateway = FakeGateway() + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "") + + assertTrue(gateway.enqueued.isEmpty()) + assertFalse(QueueViewModel.canSend("")) + // whitespace IS legitimate shell input — only EMPTY is refused + assertTrue(QueueViewModel.canSend(" ")) + } + + // ── Outcome → UI notice ────────────────────────────────────────────────────────────────────── + + @Test + fun `a full queue is reported and nothing is retried`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Full)) + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "one more") + + assertEquals(QueueNotice.Full, vm.uiState.value.notice) + assertEquals(1, gateway.enqueued.size) + } + + @Test + fun `a rate limit is surfaced and NEVER auto-retried`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.RateLimited)) + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "prompt") + + assertEquals(QueueNotice.RateLimited, vm.uiState.value.notice) + assertEquals(1, gateway.enqueued.size) // exactly one attempt — a retry burns the shared bucket + assertTrue(vm.uiState.value.isSupported) // 429 is transient, not a capability answer + } + + @Test + fun `a 503 hides the affordance for this host`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Disabled)) + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "prompt") + + assertFalse(vm.uiState.value.isSupported) + assertEquals(QueueNotice.Disabled, vm.uiState.value.notice) + } + + @Test + fun `an oversized prompt asks for a shorter one`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.TooLarge))) + vm.enqueue(SESSION, "x".repeat(64)) + assertEquals(QueueNotice.TooLarge, vm.uiState.value.notice) + } + + @Test + fun `a gone session is reported as gone`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.SessionGone))) + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.SessionGone, vm.uiState.value.notice) + } + + @Test + fun `a server rejection carries the server's own inert message`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.Rejected(403, "forbidden")))) + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.Rejected("forbidden"), vm.uiState.value.notice) + } + + @Test + fun `a transport failure surfaces as unreachable and keeps the last-good queue`() = runTest { + val vm = QueueViewModel( + object : QueueGateway { + override suspend fun snapshot(id: UUID): QueueSnapshot = QueueSnapshot(2, listOf("a", "b")) + override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome = + throw java.io.IOException("boom") + + override suspend fun clear(id: UUID): QueueWriteOutcome = QueueWriteOutcome.Ok(0) + }, + ) + + vm.refresh(SESSION) + assertEquals(listOf("a", "b"), vm.uiState.value.items) + + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.Unreachable, vm.uiState.value.notice) + assertEquals(listOf("a", "b"), vm.uiState.value.items) // last-good kept + } + + // ── Read side + cancel-all ─────────────────────────────────────────────────────────────────── + + @Test + fun `refresh publishes the queued prompts verbatim`() = runTest { + val queued = listOf("first prompt", " second\twith tabs") + val vm = QueueViewModel(FakeGateway(snapshots = listOf(QueueSnapshot(2, queued)))) + + vm.refresh(SESSION) + + assertEquals(2, vm.uiState.value.depth) + assertEquals(queued, vm.uiState.value.items) + assertNull(vm.uiState.value.notice) + } + + @Test + fun `a 404 on refresh reports the session gone`() = runTest { + val vm = QueueViewModel(FakeGateway(snapshots = listOf(ApiClientError.SessionNotFound))) + vm.refresh(SESSION) + assertEquals(QueueNotice.SessionGone, vm.uiState.value.notice) + } + + @Test + fun `cancel-all empties the queue and drives the depth from the server`() = runTest { + val gateway = FakeGateway( + snapshots = listOf(QueueSnapshot(2, listOf("a", "b"))), + writes = listOf(QueueWriteOutcome.Ok(0)), + ) + val vm = QueueViewModel(gateway) + vm.refresh(SESSION) + + vm.clearAll(SESSION) + + assertEquals(listOf(SESSION), gateway.cleared) + assertEquals(0, vm.uiState.value.depth) + assertTrue(vm.uiState.value.items.isEmpty()) + } + + // ── The live `queue` WS frame ──────────────────────────────────────────────────────────────── + + @Test + fun `the queue frame is the authoritative depth and invalidates stale items`() = runTest { + val gateway = FakeGateway(snapshots = listOf(QueueSnapshot(1, listOf("a")))) + val vm = QueueViewModel(gateway) + vm.refresh(SESSION) + + vm.onServerDepth(3) // the server drained/accepted entries we did not send + + assertEquals(3, vm.uiState.value.depth) + // The items list is now known-stale: it must NOT claim 1 entry while the depth says 3. + assertTrue(vm.uiState.value.isItemsStale) + } + + @Test + fun `a zero-depth frame clears the item list outright`() = runTest { + val vm = QueueViewModel(FakeGateway(snapshots = listOf(QueueSnapshot(1, listOf("a"))))) + vm.refresh(SESSION) + + vm.onServerDepth(0) + + assertEquals(0, vm.uiState.value.depth) + assertTrue(vm.uiState.value.items.isEmpty()) + assertFalse(vm.uiState.value.isItemsStale) + } + + @Test + fun `dismissing a notice leaves the queue itself untouched`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.Full))) + vm.refresh(SESSION) + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.Full, vm.uiState.value.notice) + + vm.dismissNotice() + + assertNull(vm.uiState.value.notice) + assertEquals(0, vm.uiState.value.depth) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListPersistenceTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListPersistenceTest.kt new file mode 100644 index 0000000..0db023b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListPersistenceTest.kt @@ -0,0 +1,293 @@ +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.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.LiveSessionInfo +import wang.yaojia.webterm.hostregistry.Host +import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore +import wang.yaojia.webterm.session.UnreadLedger +import wang.yaojia.webterm.wire.ClaudeStatus +import java.util.UUID + +/** + * [SessionListViewModel] — the two things that make it stop lying across a process death and a host + * removal: + * 1. the DURABLE watermark seam ([PersistedUnreadWatermarkStore] over `:host-registry`'s + * `SessionWatermarkStore`), including the one-shot [SessionListViewModel.useWatermarkStore] install + * the composition root uses and the rule that it can never swap a store out from under a loaded + * ledger; + * 2. host removal — which must reach the [HostRemovalGateway] with the removed host's live session ids + * (so its watermarks go too) and must NOT drop the row on failure. + * + * The reducer itself ([UnreadLedger]) is NOT retested here — this is only about WHERE the watermark + * lives. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SessionListPersistenceTest { + + 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): Host = + Host.create(id = id, name = name, baseUrl = baseUrl)!! + + fun session(id: UUID, lastOutputAt: Long? = null) = LiveSessionInfo( + id = id, + createdAt = 1_700_000_000_000L, + clientCount = 1, + status = ClaudeStatus.WORKING, + exited = false, + cwd = null, + title = null, + cols = 80, + rows = 24, + telemetry = null, + lastOutputAt = lastOutputAt, + ) + } + + private class FakeHostStore(initial: List) : HostStore { + var hosts: List = initial + override suspend fun loadAll(): List = hosts + override suspend fun upsert(host: Host): List = hosts + override suspend fun remove(id: String): List { + hosts = hosts.filterNot { it.id == id } + return hosts + } + } + + private class FakeGateway(private val list: List) : SessionListGateway { + override suspend fun liveSessions(): List = list + override suspend fun killSession(id: UUID) = Unit + } + + /** + * Stands in for the production `HostRemover`: it OWNS the removal, so it drops the host from the + * store exactly like the real one does (a fake that only records would let the VM's re-resolve pass + * against a host list no device would ever show). + */ + private class FakeRemovalGateway( + private val hosts: FakeHostStore? = null, + private val error: Throwable? = null, + ) : HostRemovalGateway { + val calls = mutableListOf>>() + override suspend fun removeHost(hostId: String, sessionIds: List) { + calls += hostId to sessionIds + error?.let { throw it } + hosts?.remove(hostId) + } + } + + // ── The durable watermark seam ──────────────────────────────────────────────────────────────── + + @Test + fun `a persisted watermark survives a new view model over the same store`() = + runTest(UnconfinedTestDispatcher()) { + val backing = InMemorySessionWatermarkStore() + val store = PersistedUnreadWatermarkStore(backing) + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val gateway = FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) + + val first = SessionListViewModel(hostStore = hosts, gatewayFactory = { gateway }, watermarkStore = store) + first.refresh() + assertTrue(first.uiState.value.rows.single().isUnread) + first.markSeen(ID_1) + assertFalse(first.uiState.value.rows.single().isUnread) + + // A fresh VM over the SAME durable store == the process restarted. + val second = SessionListViewModel(hostStore = hosts, gatewayFactory = { gateway }, watermarkStore = store) + second.refresh() + assertFalse(second.uiState.value.rows.single().isUnread) + } + + @Test + fun `the adapter round-trips the ledger map through the host-registry store`() = runTest { + val backing = InMemorySessionWatermarkStore() + val store = PersistedUnreadWatermarkStore(backing) + + store.save(UnreadLedger(mapOf(ID_1.toString() to 42L))) + + assertEquals(mapOf(ID_1.toString() to 42L), store.load().watermarks) + assertEquals(mapOf(ID_1.toString() to 42L), backing.loadAll()) + } + + @Test + fun `the adapter drops ids the durable store refuses`() = runTest { + val store = PersistedUnreadWatermarkStore(InMemorySessionWatermarkStore()) + + // "not-a-uuid" fails the frozen v4 session-id validation inside the store. + store.save(UnreadLedger(mapOf(ID_1.toString() to 7L, "not-a-uuid" to 9L))) + + assertEquals(mapOf(ID_1.toString() to 7L), store.load().watermarks) + } + + @Test + fun `useWatermarkStore installs the durable store before the first load`() = + runTest(UnconfinedTestDispatcher()) { + val backing = InMemorySessionWatermarkStore() + backing.replaceAll(mapOf(ID_1.toString() to 100L)) // already seen up to 100 + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + ) + + assertTrue(vm.useWatermarkStore(PersistedUnreadWatermarkStore(backing))) + vm.refresh() + + assertFalse(vm.uiState.value.rows.single().isUnread) // the persisted watermark was honoured + } + + @Test + fun `useWatermarkStore never overrides an explicitly injected store`() = + runTest(UnconfinedTestDispatcher()) { + val injected = InMemoryUnreadWatermarkStore(UnreadLedger(mapOf(ID_1.toString() to 100L))) + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + watermarkStore = injected, + ) + + assertFalse(vm.useWatermarkStore(InMemoryUnreadWatermarkStore())) + vm.refresh() + + assertFalse(vm.uiState.value.rows.single().isUnread) // still the injected store's watermark + } + + @Test + fun `useWatermarkStore is refused once the ledger has loaded`() = runTest(UnconfinedTestDispatcher()) { + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + ) + vm.refresh() // loads the ledger from the default in-memory store + + assertFalse(vm.useWatermarkStore(InMemoryUnreadWatermarkStore())) + } + + @Test + fun `a durable store that throws degrades to no watermarks instead of crashing`() = + runTest(UnconfinedTestDispatcher()) { + val exploding = object : UnreadWatermarkStore { + override suspend fun load(): UnreadLedger = throw IllegalStateException("keystore hiccup") + override suspend fun save(ledger: UnreadLedger) = throw IllegalStateException("disk full") + } + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + watermarkStore = exploding, + ) + + vm.refresh() + assertTrue(vm.uiState.value.rows.single().isUnread) // no watermarks → unread, never a crash + vm.markSeen(ID_1) // must not throw either + } + + // ── Host removal ───────────────────────────────────────────────────────────────────────────── + + @Test + fun `removing the active host hands the gateway its live session ids`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A), host("h2", "desktop", BASE_B))) + val removal = FakeRemovalGateway(hosts) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1), session(ID_2))) }, + removalGateway = removal, + ) + vm.refresh() + + vm.removeActiveHost() + + assertEquals( + listOf("h1" to listOf(ID_1.toString(), ID_2.toString())), + removal.calls.toList(), + ) + // The list re-resolves onto the surviving host. + assertEquals("h2", vm.uiState.value.activeHostId) + assertEquals(listOf("h2"), vm.uiState.value.hosts.map { it.id }) + } + + @Test + fun `removing the only host lands on the empty pair-a-host state`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1))) }, + removalGateway = FakeRemovalGateway(hosts), + ) + vm.refresh() + + vm.removeActiveHost() + + assertNull(vm.uiState.value.activeHostId) + assertTrue(vm.uiState.value.rows.isEmpty()) + } + + @Test + fun `a failed removal leaves the host in place rather than half-removing it`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val removal = FakeRemovalGateway(hosts, error = IllegalStateException("push DELETE failed")) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1))) }, + removalGateway = removal, + ) + vm.refresh() + + vm.removeActiveHost() + + assertEquals(1, removal.calls.size) + assertEquals("h1", vm.uiState.value.activeHostId) + assertTrue(vm.uiState.value.hasRemoveError) + } + + @Test + fun `useRemovalGateway enables removal for a view model built without one`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val removal = FakeRemovalGateway(hosts) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1))) }, + ) + vm.refresh() + + vm.removeActiveHost() // no gateway installed yet → no-op + assertTrue(removal.calls.isEmpty()) + assertEquals(1, hosts.hosts.size) + + assertTrue(vm.useRemovalGateway(removal)) + assertFalse(vm.useRemovalGateway(FakeRemovalGateway(hosts))) // one-shot + vm.removeActiveHost() + + assertEquals(listOf("h1" to listOf(ID_1.toString())), removal.calls.toList()) + assertTrue(hosts.hosts.isEmpty()) + } + + @Test + fun `removeActiveHost with no active host is a no-op`() = runTest(UnconfinedTestDispatcher()) { + val removal = FakeRemovalGateway() + val vm = SessionListViewModel( + hostStore = FakeHostStore(emptyList()), + gatewayFactory = { FakeGateway(emptyList()) }, + removalGateway = removal, + ) + vm.refresh() + + vm.removeActiveHost() + + assertTrue(removal.calls.isEmpty()) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SurfaceCopyMappingTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SurfaceCopyMappingTest.kt new file mode 100644 index 0000000..e8eb8a1 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SurfaceCopyMappingTest.kt @@ -0,0 +1,106 @@ +package wang.yaojia.webterm.viewmodels + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.components.queueNoticeText +import wang.yaojia.webterm.screens.copyResultText +import wang.yaojia.webterm.screens.rotationStatusText +import wang.yaojia.webterm.terminalview.TerminalCopyResult +import wang.yaojia.webterm.wiring.RotationOutcome +import wang.yaojia.webterm.wiring.RotationStage +import java.time.Duration +import java.time.Instant + +/** + * The pure copy mappings behind three surfaces whose PIXELS are device-QA but whose *choice of what to + * say* is a correctness property: + * - [queueNoticeText] — every queue failure has its own honest line, and a server-supplied message is + * never allowed to render as the literal "null"; + * - [rotationStatusText] — a silent renewal stays SILENT, and only the three actionable outcomes speak; + * - [copyResultText] — a refused clipboard write says so instead of pretending it copied. + * None of them may leak a secret: no copied text, no `Throwable.message`, no credential. + */ +class SurfaceCopyMappingTest { + + // ── Queue notices ──────────────────────────────────────────────────────────────────────────── + + @Test + fun `every queue notice has its own non-blank line`() { + val notices = listOf( + QueueNotice.Full, + QueueNotice.TooLarge, + QueueNotice.Disabled, + QueueNotice.SessionGone, + QueueNotice.RateLimited, + QueueNotice.Unreachable, + QueueNotice.Rejected("forbidden"), + ) + val texts = notices.map(::queueNoticeText) + + assertTrue(texts.all { it.isNotBlank() }) + assertEquals(notices.size, texts.distinct().size, "each notice needs its own copy: $texts") + } + + @Test + fun `the rate-limit line states the shared limit and offers no retry`() { + val text = queueNoticeText(QueueNotice.RateLimited) + assertTrue(text.contains("20"), text) // the shared 20/min/IP bucket, named honestly + assertTrue(text.contains("手动"), text) // any retry is the USER's, never automatic + } + + @Test + fun `a rejection with no server message falls back to app copy`() { + val text = queueNoticeText(QueueNotice.Rejected(null)) + assertTrue(text.isNotBlank()) + assertFalse(text.contains("null"), text) + } + + @Test + fun `a server message is surfaced verbatim`() { + assertTrue(queueNoticeText(QueueNotice.Rejected("worktree is dirty")).contains("worktree is dirty")) + } + + // ── Rotation status ────────────────────────────────────────────────────────────────────────── + + @Test + fun `a healthy or in-progress rotation says nothing`() { + assertNull(rotationStatusText(null)) + assertNull(rotationStatusText(RotationOutcome.NotEnrolled)) + assertNull(rotationStatusText(RotationOutcome.Renewed)) + assertNull(rotationStatusText(RotationOutcome.Recovered)) + assertNull(rotationStatusText(RotationOutcome.NotDue(Instant.EPOCH))) + assertNull(rotationStatusText(RotationOutcome.BackingOff(Instant.EPOCH))) + } + + @Test + fun `the three actionable outcomes all speak`() { + assertNotNull(rotationStatusText(RotationOutcome.ReEnrollRequired(Duration.ofDays(40)))) + assertNotNull(rotationStatusText(RotationOutcome.NotConfigured("device-1"))) + assertNotNull(rotationStatusText(RotationOutcome.Failed(RotationStage.RENEW, "Http(429)"))) + } + + @Test + fun `a failure line carries the error SHAPE and says the current cert still works`() { + val text = rotationStatusText(RotationOutcome.Failed(RotationStage.RECOVER, "Http(403,revoked)")) + assertNotNull(text) + assertTrue(text!!.contains("Http(403,revoked)"), text) + assertTrue(text.contains("recover"), text) // which STEP failed + assertTrue(text.contains("仍然有效"), text) // the prior identity is untouched + } + + // ── Copy-out ───────────────────────────────────────────────────────────────────────────────── + + @Test + fun `every copy outcome is reported distinctly and a refusal is not called success`() { + val texts = TerminalCopyResult.entries.map(::copyResultText) + copyResultText(null) + + assertTrue(texts.all { it.isNotBlank() }) + assertEquals(texts.size, texts.distinct().size, "each copy outcome needs its own copy: $texts") + // A refused clipboard write must NOT read like a success. + assertFalse(copyResultText(TerminalCopyResult.CLIPBOARD_UNAVAILABLE).contains("已复制")) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt new file mode 100644 index 0000000..91ac004 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt @@ -0,0 +1,183 @@ +package wang.yaojia.webterm.wiring + +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.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import wang.yaojia.webterm.wire.HttpResponse +import wang.yaojia.webterm.wire.HttpTransport +import java.io.IOException + +/** + * [AccessTokenGateway] — the `WEBTERM_TOKEN` half of pairing, verified against the server contract in + * `src/http/auth.ts` + `src/server.ts` (`POST /auth`: urlencoded `token` field; XHR ⇒ 204 valid / 401 + * invalid / 429 over the 10-per-minute limit; the global gate answers an unauthed read with 401). + * + * The credential-hygiene assertions are as load-bearing as the status mapping: the token is a SHELL + * credential, so it must appear ONLY in the request body and never in a URL, a header, a returned + * outcome, or any `toString()` that could reach a log or a crash report. + */ +@DisplayName("AccessTokenGateway — POST /auth + 401 gate detection") +class AccessTokenGatewayTest { + + private companion object { + /** A token using the full server-side charset (`[A-Za-z0-9._~+/=-]`) — `+`, `/`, `=` are the + * form-encoding traps: a raw `+` in an urlencoded body decodes to a SPACE server-side. */ + const val TOKEN = "ab+cd/ef=gh-ij._~" + const val BASE = "http://10.0.0.5:3000" + } + + private class RecordingTransport( + private val handler: (HttpRequest) -> HttpResponse, + ) : HttpTransport { + val requests = mutableListOf() + + override suspend fun send(request: HttpRequest): HttpResponse { + requests += request + return handler(request) + } + } + + private class ThrowingTransport(private val error: Throwable) : HttpTransport { + override suspend fun send(request: HttpRequest): HttpResponse = throw error + } + + private fun endpoint(url: String = BASE): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" } + + private fun status(code: Int): (HttpRequest) -> HttpResponse = + { HttpResponse(status = code, body = ByteArray(0)) } + + private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport } + + // ── Gate detection: is this host token-gated, or is it just not a web-terminal? ────────────────── + + @Test + fun `a 401 on the unauthenticated read means the host is token-gated`() = runTest { + val transport = RecordingTransport(status(401)) + + assertTrue(gateway(transport).requiresAccessToken(endpoint())) + assertEquals("$BASE/live-sessions", transport.requests.single().url) + assertEquals(HttpMethod.GET, transport.requests.single().method) + // The read is Origin-free (plan §4.3: Origin only on guarded routes). + assertTrue(transport.requests.single().headers.keys.none { it.equals("Origin", ignoreCase = true) }) + } + + @Test + fun `a 200 host is not token-gated`() = runTest { + assertFalse(gateway(RecordingTransport(status(200))).requiresAccessToken(endpoint())) + } + + @Test + fun `some other 4xx or 5xx is not read as the token gate`() = runTest { + for (code in listOf(403, 404, 418, 500, 503)) { + assertFalse( + gateway(RecordingTransport(status(code))).requiresAccessToken(endpoint()), + "HTTP $code must not be mistaken for the WEBTERM_TOKEN gate", + ) + } + } + + @Test + fun `an unreachable host is not reported as token-gated`() = runTest { + // "Nothing answered" must stay distinguishable from "answered 401" — never prompt for a + // credential because the host is down. + assertFalse(gateway(ThrowingTransport(IOException("connect refused"))).requiresAccessToken(endpoint())) + } + + // ── POST /auth status mapping ──────────────────────────────────────────────────────────────────── + + @Test + fun `204 accepts the token`() = runTest { + assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN)) + } + + @Test + fun `401 is a wrong token, distinct from an unreachable host`() = runTest { + assertEquals(TokenSubmission.Rejected, gateway(RecordingTransport(status(401))).submit(endpoint(), TOKEN)) + + val unreachable = gateway(ThrowingTransport(IOException("no route"))).submit(endpoint(), TOKEN) + assertInstanceOf(TokenSubmission.Unreachable::class.java, unreachable) + } + + @Test + fun `429 is surfaced as rate-limited and never auto-retried`() = runTest { + val transport = RecordingTransport(status(429)) + + assertEquals(TokenSubmission.RateLimited, gateway(transport).submit(endpoint(), TOKEN)) + assertEquals(1, transport.requests.size, "a 429 must not be retried inside the gateway") + } + + @Test + fun `an unexpected status is reported as unreachable, not as a wrong token`() = runTest { + val outcome = gateway(RecordingTransport(status(500))).submit(endpoint(), TOKEN) + + assertInstanceOf(TokenSubmission.Unreachable::class.java, outcome) + assertEquals("HTTP 500", (outcome as TokenSubmission.Unreachable).reason) + } + + // ── Request shape (the server contract) ────────────────────────────────────────────────────────── + + @Test + fun `the token is posted as an urlencoded form field with every reserved byte escaped`() = runTest { + val transport = RecordingTransport(status(204)) + + gateway(transport).submit(endpoint(), TOKEN) + + val request = transport.requests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/auth", request.url) + assertEquals( + "application/x-www-form-urlencoded", + request.headers.entries.single { it.key.equals("Content-Type", ignoreCase = true) }.value, + ) + // `+` MUST NOT survive raw (Express's qs decodes it to a space) and `/`/`=` would split the field. + assertEquals("token=ab%2Bcd%2Fef%3Dgh-ij._~", request.body?.decodeToString()) + } + + @Test + fun `no Accept text-html header is sent, so the server answers 204 or 401 instead of redirecting`() = + runTest { + val transport = RecordingTransport(status(204)) + + gateway(transport).submit(endpoint(), TOKEN) + + val accept = transport.requests.single().headers.entries + .firstOrNull { it.key.equals("Accept", ignoreCase = true) }?.value.orEmpty() + assertFalse(accept.contains("text/html"), "an HTML Accept makes /auth answer 302, not 204/401") + } + + // ── Credential hygiene ────────────────────────────────────────────────────────────────────────── + + @Test + fun `the token never reaches the URL, a header, the outcome or any toString`() = runTest { + val transport = RecordingTransport(status(401)) + + val outcome = gateway(transport).submit(endpoint(), TOKEN) + + val request = transport.requests.single() + assertFalse(request.url.contains(TOKEN), "never put the credential in a URL (logs / Referer)") + assertFalse(request.headers.toString().contains(TOKEN)) + assertFalse(outcome.toString().contains(TOKEN)) + // The data class renders `body=[B@…` — the bytes are never stringified. + assertFalse(request.toString().contains(TOKEN)) + } + + @Test + fun `a transport failure reports only the error type, never the message or the token`() = runTest { + // An exception message is attacker/host-influenced and could echo a request; carry the type only. + val error = IOException("failed posting token=$TOKEN") + val outcome = gateway(ThrowingTransport(error)).submit(endpoint(), TOKEN) + + val reason = (outcome as TokenSubmission.Unreachable).reason + assertEquals("IOException", reason) + assertFalse(outcome.toString().contains(TOKEN)) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieHydratorTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieHydratorTest.kt new file mode 100644 index 0000000..7d1fe7b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieHydratorTest.kt @@ -0,0 +1,174 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +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.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.hostregistry.AuthCookieId +import wang.yaojia.webterm.hostregistry.AuthCookieRecord +import wang.yaojia.webterm.hostregistry.AuthCookieStore +import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore +import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME +import wang.yaojia.webterm.transport.AuthCookieJar + +/** + * [AuthCookieHydrator] — the cold-start half of the `WEBTERM_TOKEN` gate: without it the durable + * `webterm_auth` cookie never reaches the live [AuthCookieJar], so a paired token-gated host would ask + * for the token again on every launch. + * + * Two properties are security-load-bearing and asserted directly: + * - **per-host isolation** — a record filed under host A must never be presented to host B; + * - **hydrate exactly once** — a second hydration could overwrite a FRESHER in-memory cookie with the + * stale persisted one (the durable write is asynchronous), silently reverting to a dead credential. + */ +@DisplayName("AuthCookieHydrator — cold-start restore of the webterm_auth cookie") +class AuthCookieHydratorTest { + + private companion object { + const val HOST_A = "http://10.0.0.5:3000" + const val HOST_B = "http://10.0.0.9:3000" + const val TOKEN = "persisted-shell-credential" + const val FAR_FUTURE = 4_000_000_000_000L // ~2096 + } + + private fun record( + hostKey: String, + host: String, + value: String = TOKEN, + expiresAt: Long = FAR_FUTURE, + ) = AuthCookieRecord( + hostKey = hostKey, + name = AUTH_COOKIE_NAME, + value = value, + domain = host, + path = "/", + expiresAtEpochMillis = expiresAt, + secure = false, + httpOnly = true, + hostOnly = true, + ) + + private fun hydrator(store: AuthCookieStore, jar: AuthCookieJar) = + AuthCookieHydrator(store = { store }, jar = { jar }) + + private fun AuthCookieJar.valuesFor(baseUrl: String): List = + loadForRequest("$baseUrl/live-sessions".toHttpUrl()).map { it.value } + + @Test + fun `a persisted cookie is restored onto its own host and nowhere else`() = runTest { + val store = InMemoryAuthCookieStore( + listOf(record(HOST_A, "10.0.0.5"), record(HOST_B, "10.0.0.9", value = "other-host-token")), + ) + val jar = AuthCookieJar() + + hydrator(store, jar).hydrateOnce() + + assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A)) + assertEquals(listOf("other-host-token"), jar.valuesFor(HOST_B)) + } + + @Test + fun `an expired record is not resurrected`() = runTest { + val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5", expiresAt = 1L))) + val jar = AuthCookieJar() + + hydrator(store, jar).hydrateOnce() + + assertTrue(jar.valuesFor(HOST_A).isEmpty()) + } + + @Test + fun `hydration happens exactly once, so a fresher in-memory cookie is never clobbered`() = runTest { + val store = CountingStore(listOf(record(HOST_A, "10.0.0.5", value = "stale"))) + val jar = AuthCookieJar() + val hydrator = hydrator(store, jar) + + hydrator.hydrateOnce() + assertEquals(listOf("stale"), jar.valuesFor(HOST_A)) + + // A live re-auth replaces the credential in memory (its durable write is async and may lag). + jar.saveFromResponse( + "$HOST_A/auth".toHttpUrl(), + listOf( + okhttp3.Cookie.Builder() + .name(AUTH_COOKIE_NAME) + .value("fresh") + .expiresAt(FAR_FUTURE) + .path("/") + .hostOnlyDomain("10.0.0.5") + .build(), + ), + ) + + hydrator.hydrateOnce() + + assertEquals(1, store.loads, "a second warm-up must not re-read the store") + assertEquals(listOf("fresh"), jar.valuesFor(HOST_A)) + } + + @Test + fun `a failing store never breaks warm-up and is retried on the next one`() = runTest { + val store = FailingThenWorkingStore(listOf(record(HOST_A, "10.0.0.5"))) + val jar = AuthCookieJar() + val hydrator = hydrator(store, jar) + + hydrator.hydrateOnce() // throws inside → swallowed + assertTrue(jar.valuesFor(HOST_A).isEmpty()) + + hydrator.hydrateOnce() // the failure did not consume the one-shot + assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A)) + } + + @Test + fun `the restored credential is not exposed by the jar or record toString`() = runTest { + val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5"))) + val jar = AuthCookieJar() + + hydrator(store, jar).hydrateOnce() + + assertFalse(jar.toString().contains(TOKEN)) + assertFalse(store.loadAll().toString().contains(TOKEN)) + } + + // ── Doubles ───────────────────────────────────────────────────────────────────────────────────── + + private class CountingStore(initial: List) : AuthCookieStore { + private val delegate = InMemoryAuthCookieStore(initial) + var loads: Int = 0 + + override suspend fun loadAll(): List { + loads++ + return delegate.loadAll() + } + + override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey) + override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record) + override suspend fun remove(id: AuthCookieId) = delegate.remove(id) + override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey) + override suspend fun replaceHost(hostKey: String, records: List) = + delegate.replaceHost(hostKey, records) + } + + private class FailingThenWorkingStore(initial: List) : AuthCookieStore { + private val delegate = InMemoryAuthCookieStore(initial) + private var failed = false + + override suspend fun loadAll(): List { + if (!failed) { + failed = true + error("keystore unavailable") + } + return delegate.loadAll() + } + + override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey) + override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record) + override suspend fun remove(id: AuthCookieId) = delegate.remove(id) + override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey) + override suspend fun replaceHost(hostKey: String, records: List) = + delegate.replaceHost(hostKey, records) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieMappingTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieMappingTest.kt new file mode 100644 index 0000000..6d7310f --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieMappingTest.kt @@ -0,0 +1,62 @@ +package wang.yaojia.webterm.wiring + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME +import wang.yaojia.webterm.transport.AuthCookieSnapshot + +/** + * The `:app`-owned bridge between `:transport-okhttp`'s [AuthCookieSnapshot] (what the live cookie jar + * publishes) and `:host-registry`'s `AuthCookieRecord` (what is encrypted at rest). Neither module may + * depend on the other (`android/README.md`: dependencies only flow down), so this 2-function mapping is + * the seam — and a field dropped here is a silently broken credential, hence the field-for-field test. + */ +@DisplayName("AuthCookie snapshot ⇄ record mapping") +class AuthCookieMappingTest { + + private companion object { + const val HOST_KEY = "https://star.terminal.yaojia.wang:443" + const val TOKEN = "shell-credential" + } + + private fun snapshot() = AuthCookieSnapshot( + name = AUTH_COOKIE_NAME, + value = TOKEN, + domain = "star.terminal.yaojia.wang", + path = "/", + expiresAtEpochMillis = 4_000_000_000_000L, + secure = true, + httpOnly = true, + hostOnly = true, + ) + + @Test + fun `every field survives the round trip, with the host key stamped on the record`() { + val original = snapshot() + + val record = original.toRecord(HOST_KEY) + + assertEquals(HOST_KEY, record.hostKey) + assertEquals(original.name, record.name) + assertEquals(original.value, record.value) + assertEquals(original.domain, record.domain) + assertEquals(original.path, record.path) + // ABSOLUTE wall-clock expiry, never a Max-Age duration (which would restart on every restore). + assertEquals(original.expiresAtEpochMillis, record.expiresAtEpochMillis) + assertEquals(original.secure, record.secure) + assertEquals(original.httpOnly, record.httpOnly) + assertEquals(original.hostOnly, record.hostOnly) + + assertEquals(original, record.toSnapshot()) + } + + @Test + fun `neither side renders the credential in toString`() { + val record = snapshot().toRecord(HOST_KEY) + + assertFalse(record.toString().contains(TOKEN)) + assertFalse(record.toSnapshot().toString().contains(TOKEN)) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationSchedulerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationSchedulerTest.kt new file mode 100644 index 0000000..cb50104 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationSchedulerTest.kt @@ -0,0 +1,331 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +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.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.api.enroll.DeviceRenewalState +import wang.yaojia.webterm.api.enroll.RotationPolicy +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.tlsandroid.CertStore +import wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot +import wang.yaojia.webterm.tlsandroid.EnrollmentRecord +import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore +import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher +import wang.yaojia.webterm.tlsandroid.StoredIdentityMetadata +import java.io.IOException +import java.time.Duration +import java.time.Instant + +/** + * The silent-rotation DRIVER: it reads the installed identity's timing, asks the pure `RotationPolicy` + * what to do, and drives the matching call. iOS ships `CertificateRotationScheduler`; Android had no + * counterpart at all, so a device certificate simply died. + * + * The behaviours under test here are the ones that make it safe to fire on every app foreground: + * - it is IDEMPOTENT and single-flight — a second trigger while one pass is in flight coalesces, + * - a failure is SURFACED (observable outcome) and leaves the prior identity fully live, + * - a failure THROTTLES the next attempt instead of hammering a rate-limited control plane, and + * - nothing it logs or surfaces can carry a credential. + */ +class CertificateRotationSchedulerTest { + private companion object { + val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z") + val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z") + const val CP_URL = "https://cp.terminal.yaojia.wang" + + /** A base64-looking string standing in for credential material in an error message. */ + const val LEAKY_SECRET = "MIIBfzCCASWgAwIBAgIUSECRETCSRBYTES" + } + + private val logLines = mutableListOf() + private var clock: Instant = RENEW_AFTER + private var snapshot: DeviceRotationSnapshot? = snapshot() + private var readFault: Exception? = null + private val renewUrls = mutableListOf() + private val recoverUrls = mutableListOf() + private var renewFault: Exception? = null + private var recoverFault: Exception? = null + + /** Optional gate a test can arm to hold `renew` suspended (single-flight coalescing). */ + private var renewGate: CompletableDeferred? = null + + private fun snapshot( + notAfter: Instant? = NOT_AFTER, + renewAfter: Instant? = RENEW_AFTER, + controlPlaneUrl: String = CP_URL, + ): DeviceRotationSnapshot = DeviceRotationSnapshot( + renewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter), + controlPlaneUrl = controlPlaneUrl, + ) + + private fun scheduler(): CertificateRotationScheduler = CertificateRotationScheduler( + readState = { + readFault?.let { throw it } + snapshot + }, + renew = { url -> + renewUrls += url + renewGate?.await() + renewFault?.let { throw it } + }, + recover = { url -> + recoverUrls += url + recoverFault?.let { throw it } + }, + now = { clock }, + log = { logLines += it }, + ) + + // ── the wait branches ────────────────────────────────────────────────────────────────────── + + @Test + fun nothingEnrolledDoesNoWorkAtAll() = runTest { + snapshot = null + + assertEquals(RotationOutcome.NotEnrolled, scheduler().runIfDue()) + + assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty()) + } + + @Test + fun aCertificateInsideItsRenewWindowIsLeftAlone() = runTest { + clock = RENEW_AFTER.minusSeconds(1) + + assertEquals(RotationOutcome.NotDue(RENEW_AFTER), scheduler().runIfDue()) + + assertTrue(renewUrls.isEmpty(), "the common case must be free") + } + + // ── renew ────────────────────────────────────────────────────────────────────────────────── + + @Test + fun aDueCertificateRenewsAgainstThePersistedControlPlane() = runTest { + val subject = scheduler() + + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + + assertEquals(listOf(CP_URL), renewUrls, "renew targets the control plane the device enrolled with") + assertTrue(recoverUrls.isEmpty()) + assertEquals(RotationOutcome.Renewed, subject.lastOutcome.value) + } + + @Test + fun aBlankControlPlaneUrlRefusesToGuessAHost() = runTest { + // A record written before the URL was persisted has no rotation target. Renewing against a + // compiled-in default would talk to somebody else's control plane. + snapshot = snapshot(controlPlaneUrl = " ") + + assertEquals(RotationOutcome.NotConfigured("dev-1"), scheduler().runIfDue()) + + assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty(), "no request without a known host") + } + + // ── recover ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun anExpiredCertificateRecoversAndNeverAttemptsAnMtlsRenew() = runTest { + clock = NOT_AFTER.plus(Duration.ofDays(8)) + + assertEquals(RotationOutcome.Recovered, scheduler().runIfDue()) + + // An expired leaf cannot authenticate its own renewal, so a renew attempt here is not merely + // useless — it is the production deadlock this whole path exists to break. + assertTrue(renewUrls.isEmpty(), "an expired leaf must never be sent to the mTLS renew route") + assertEquals(listOf(CP_URL), recoverUrls) + } + + @Test + fun aCertificateExpiredBeyondTheGraceWindowIsTerminalAndSilent() = runTest { + clock = NOT_AFTER.plus(Duration.ofDays(31)) + + val outcome = scheduler().runIfDue() + + assertEquals(RotationOutcome.ReEnrollRequired(Duration.ofDays(31)), outcome) + assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty(), "no request can succeed — make none") + } + + // ── failure posture ──────────────────────────────────────────────────────────────────────── + + @Test + fun aFailedRenewIsSurfacedAndTheStageIsIdentified() = runTest { + renewFault = DeviceEnrollmentError.Http(429, "rate_limited") + val subject = scheduler() + + val outcome = subject.runIfDue() + + assertEquals(RotationOutcome.Failed(RotationStage.RENEW, "Http(429,rate_limited)"), outcome) + // Observable, not only a log line — iOS surfaces the same as `isCertificateRenewalFailing`. + assertEquals(outcome, subject.lastOutcome.value) + } + + @Test + fun aFailedRecoveryIsSurfacedSeparatelyFromAFailedRenew() = runTest { + clock = NOT_AFTER.plus(Duration.ofDays(8)) + recoverFault = IOException("connection reset") + + assertEquals( + RotationOutcome.Failed(RotationStage.RECOVER, "IOException"), + scheduler().runIfDue(), + ) + } + + @Test + fun aStateReadFaultIsSurfacedRatherThanReportedAsNotEnrolled() = runTest { + // Degrading a Tink/keystore fault to "nothing enrolled" would disable rotation forever. + readFault = IllegalStateException("keystore unavailable") + + assertEquals( + RotationOutcome.Failed(RotationStage.READ_STATE, "IllegalStateException"), + scheduler().runIfDue(), + ) + } + + @Test + fun aFailureThrottlesTheNextPassInsteadOfHammering() = runTest { + renewFault = IOException("connection reset") + val subject = scheduler() + subject.runIfDue() + + clock = RENEW_AFTER.plus(Duration.ofMinutes(1)) + val second = subject.runIfDue() + + assertEquals( + RotationOutcome.BackingOff(RENEW_AFTER.plus(RotationPolicy.DEFAULT_FAILURE_BACKOFF)), + second, + ) + assertEquals(1, renewUrls.size, "the control plane rate-limits renewals — one failure, one call") + } + + @Test + fun aSuccessClearsTheThrottle() = runTest { + renewFault = IOException("connection reset") + val subject = scheduler() + subject.runIfDue() + + renewFault = null + clock = RENEW_AFTER.plus(RotationPolicy.DEFAULT_FAILURE_BACKOFF) + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + // Still due (the fake state does not change), so a following pass attempts again immediately — + // proving the recorded failure was cleared rather than throttling forever. + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + assertEquals(3, renewUrls.size) + } + + // ── single-flight / idempotence ──────────────────────────────────────────────────────────── + + @Test + fun aSecondTriggerWhileOneIsInFlightCoalescesIntoTheSamePass() = runTest { + val gate = CompletableDeferred() + renewGate = gate + val subject = scheduler() + + val first = async { subject.runIfDue() } + val second = async { subject.runIfDue() } + // Let both actually start: the first parks inside `renew`, the second must then find the pass + // already in flight and park on ITS result. Without this the first pass would finish before + // the second even began, and the test would prove nothing. + testScheduler.runCurrent() + gate.complete(Unit) + + assertEquals(RotationOutcome.Renewed, first.await()) + assertEquals(RotationOutcome.Renewed, second.await()) + assertEquals(1, renewUrls.size, "two foreground triggers must not stampede two renewals") + } + + @Test + fun aCancelledPassDoesNotWedgeTheScheduler() = runTest { + val gate = CompletableDeferred() + renewGate = gate + val subject = scheduler() + + val cancelled = launch { subject.runIfDue() } + cancelled.cancel() + cancelled.join() + + // The in-flight slot must have been released, or every later pass would await a deferred that + // can never complete — rotation dead until the process restarts. + renewGate = null + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + } + + // ── credential hygiene ───────────────────────────────────────────────────────────────────── + + @Test + fun neitherTheLogNorTheOutcomeCanCarryACredential() = runTest { + // A real exception message can embed a URL, a body or, as here, cert/CSR material. The driver + // must report the error SHAPE only — never the message. + renewFault = IllegalStateException("csr=$LEAKY_SECRET token=hunter2") + val subject = scheduler() + + val outcome = subject.runIfDue() + + val rendered = "$outcome ${logLines.joinToString(" ")}" + assertFalse(rendered.contains(LEAKY_SECRET), "no certificate/CSR material may be logged or surfaced") + assertFalse(rendered.contains("hunter2"), "no token/passphrase may be logged or surfaced") + assertFalse(rendered.contains("csr="), "not even the message fragment") + assertEquals(RotationOutcome.Failed(RotationStage.RENEW, "IllegalStateException"), outcome) + } + + @Test + fun theSuccessLogNamesTheDeviceAndNothingElse() = runTest { + scheduler().runIfDue() + + assertTrue(logLines.isNotEmpty(), "a silent rotation must still leave a trace") + val rendered = logLines.joinToString(" ") + assertTrue(rendered.contains("dev-1"), "the non-secret device id is what makes the line useful") + assertFalse(rendered.contains("MII"), "no DER/PEM material") + } + + @Test + fun theLastOutcomeStartsEmpty() = runTest { + assertNull(scheduler().lastOutcome.value, "nothing is claimed before a pass has run") + } + + // ── the composition root ─────────────────────────────────────────────────────────────────── + + @Test + fun createBuildsAWorkingSchedulerOverTheRealCollaborators() = runTest { + // `create` is the ONE place that knows both halves (:api-client + :client-tls-android). This + // smoke test runs it over the real DeviceRotationStore / DeviceEnroller types with empty + // stores, so a broken composition root fails here rather than silently at runtime. + val scheduler = CertificateRotationScheduler.create( + certStore = EmptyCertStore, + recordStore = EmptyRecordStore, + cacheRefresher = IdentityCacheRefresher {}, + sharedClient = { OkHttpClient() }, + mtlsTransport = { FakeHttpTransport() }, + plainTransport = { FakeHttpTransport() }, + ioDispatcher = StandardTestDispatcher(testScheduler), + now = { clock }, + log = { logLines += it }, + ) + + assertEquals(RotationOutcome.NotEnrolled, scheduler.runIfDue()) + } + + private object EmptyCertStore : CertStore { + override fun save(metadata: StoredIdentityMetadata): Unit = error("not used") + + override fun load(): StoredIdentityMetadata? = null + + override fun clear(): Unit = error("not used") + } + + private object EmptyRecordStore : EnrollmentRecordStore { + override fun save(record: EnrollmentRecord): Unit = error("not used") + + override fun load(): EnrollmentRecord? = null + + override fun clear(): Unit = error("not used") + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationTriggerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationTriggerTest.kt new file mode 100644 index 0000000..2d77f9f --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationTriggerTest.kt @@ -0,0 +1,89 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.TestScope +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 java.time.Instant + +/** + * [CertificateRotationTrigger] — the app-lifecycle edge that finally CALLS + * [CertificateRotationScheduler]. Without it the scheduler is dead code and a device certificate simply + * expires, stranding the app (the iOS `AppCoordinator.runCertificateRotationIfDue()` counterpart). + * + * The trigger is deliberately fire-and-forget: `AppEnvironment.warmUp()` is awaited by the terminal + * screen's readiness gate, so a rotation's NETWORK call must never be able to hold the UI. The + * properties asserted here are the ones that make that safe — it launches rather than suspends, it never + * lets a failure escape, and a scheduler that cannot even be constructed does not crash the app. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CertificateRotationTriggerTest { + + /** A scheduler whose store read is scripted; `null` state ⇒ NotEnrolled (no network at all). */ + private fun scheduler( + readState: suspend () -> wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot? = { null }, + ) = CertificateRotationScheduler( + readState = readState, + renew = { }, + recover = { }, + now = { Instant.parse("2026-07-30T00:00:00Z") }, + log = { }, + ) + + @Test + fun `firing runs one rotation pass and publishes its outcome`() = runTest(UnconfinedTestDispatcher()) { + val live = scheduler() + val trigger = CertificateRotationTrigger(scope = this, scheduler = { live }) + + trigger.fire() + + assertEquals(RotationOutcome.NotEnrolled, live.lastOutcome.value) + } + + @Test + fun `a store fault becomes a Failed outcome, never a thrown exception`() = + runTest(UnconfinedTestDispatcher()) { + val live = scheduler(readState = { throw IllegalStateException("keystore unavailable") }) + val trigger = CertificateRotationTrigger(scope = this, scheduler = { live }) + + trigger.fire() // must not throw + + val outcome = live.lastOutcome.value + assertTrue(outcome is RotationOutcome.Failed, "expected Failed, got $outcome") + assertEquals(RotationStage.READ_STATE, (outcome as RotationOutcome.Failed).stage) + // Only the SHAPE is kept — never a message that could embed a URL or credential material. + assertEquals("IllegalStateException", outcome.errorShape) + } + + @Test + fun `a scheduler that cannot be resolved is reported, not crashed`() = runTest(UnconfinedTestDispatcher()) { + val errors = mutableListOf() + val trigger = CertificateRotationTrigger( + scope = this, + scheduler = { throw IllegalStateException("graph not ready") }, + onError = { errors += it }, + ) + + trigger.fire() + + assertEquals(1, errors.size) + } + + @Test + fun `fire never suspends the caller on the rotation itself`() = runTest { + // A scheduler whose read blocks forever: `fire()` must still return immediately. If it awaited the + // pass, this test would hang instead of completing (runTest would report the coroutine as stuck). + val live = scheduler(readState = { kotlinx.coroutines.awaitCancellation() }) + val scope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val trigger = CertificateRotationTrigger(scope = scope, scheduler = { live }) + + trigger.fire() + + assertNull(live.lastOutcome.value) // still in flight, and we got control back + scope.coroutineContext[kotlinx.coroutines.Job]?.cancel() + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt new file mode 100644 index 0000000..9843f2e --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt @@ -0,0 +1,192 @@ +package wang.yaojia.webterm.wiring + +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.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.hostregistry.AuthCookieRecord +import wang.yaojia.webterm.hostregistry.Host +import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore +import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore +import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore +import java.util.UUID + +/** + * [HostRemover] — un-pairing a host must erase EVERY trace keyed off it, in the one order that works. + * + * The bug this closes: `PushRegistrar.unregisterHost` had zero call sites, so a dropped host kept the + * device's FCM token forever and kept pushing notifications for a host the user had removed. + * + * Two rules are asserted because getting either wrong is worse than not removing at all: + * - **the remote DELETE happens FIRST**, while the host record and its `webterm_auth` cookie still + * exist — erase them first and the DELETE can neither be addressed nor authenticated; + * - **local erasure is all-or-nothing and `HostStore.remove` is LAST**, so a failure part-way leaves the + * host paired and the operation retryable, never a ghost with orphaned cookies. + * The remote DELETE itself is BEST-EFFORT: a host that is switched off must still be removable. + */ +class HostRemoverTest { + + private companion object { + const val BASE = "http://laptop.local:3000" + val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + val SESSION_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666") + + fun host(id: String = "h1"): Host = Host.create(id = id, name = "laptop", baseUrl = BASE)!! + } + + private class RecordingHostStore(initial: List) : HostStore { + var hosts: List = initial + var removeError: Throwable? = null + override suspend fun loadAll(): List = hosts + override suspend fun upsert(host: Host): List = hosts + override suspend fun remove(id: String): List { + removeError?.let { throw it } + hosts = hosts.filterNot { it.id == id } + return hosts + } + } + + /** Records the (host, token) pairs handed to the push DELETE — and whether it was called at all. */ + private class RecordingPush(private val error: Throwable? = null) : HostPushUnregister { + val calls = mutableListOf>() + var hostStillPairedAtCallTime: Boolean? = null + var probe: (() -> Boolean)? = null + + override suspend fun unregister(host: Host, token: String) { + hostStillPairedAtCallTime = probe?.invoke() + calls += host.id to token + error?.let { throw it } + } + } + + private suspend fun fixture( + hosts: RecordingHostStore = RecordingHostStore(listOf(host())), + push: RecordingPush = RecordingPush(), + token: String? = "fcm-token", + cookieJarClears: MutableList = mutableListOf(), + ): Triple { + val lastSession = InMemoryLastSessionStore() + val cookies = InMemoryAuthCookieStore() + val watermarks = InMemorySessionWatermarkStore() + lastSession.setLastSessionId(SESSION_1.toString(), "h1") + cookies.upsert( + AuthCookieRecord( + hostKey = "http://laptop.local:3000", + name = "webterm_auth", + value = "secret", + domain = "laptop.local", + path = "/", + expiresAtEpochMillis = Long.MAX_VALUE, + secure = false, + httpOnly = true, + hostOnly = true, + ), + ) + watermarks.replaceAll(mapOf(SESSION_1.toString() to 10L, SESSION_2.toString() to 20L)) + val remover = HostRemover( + hostStore = hosts, + lastSessionStore = lastSession, + authCookieStore = cookies, + watermarkStore = watermarks, + pushUnregister = push, + currentPushToken = { token }, + clearLiveCookies = { key -> cookieJarClears += key }, + log = { }, // android.util.Log is not mocked under JVM unit tests + ) + return Triple(remover, push, Quad(hosts, lastSession, cookies, watermarks)) + } + + private data class Quad( + val hosts: RecordingHostStore, + val lastSession: InMemoryLastSessionStore, + val cookies: InMemoryAuthCookieStore, + val watermarks: InMemorySessionWatermarkStore, + ) + + @Test + fun `removing a host erases every trace keyed off it`() = runTest { + val jarClears = mutableListOf() + val (remover, push, stores) = fixture(cookieJarClears = jarClears) + + remover.removeHost("h1", listOf(SESSION_1.toString(), SESSION_2.toString())) + + assertEquals(listOf("h1" to "fcm-token"), push.calls.toList()) // the DELETE finally happens + assertTrue(stores.hosts.hosts.isEmpty()) + assertNull(stores.lastSession.lastSessionId("h1")) + assertTrue(stores.cookies.loadAll().isEmpty()) + assertEquals(listOf("http://laptop.local:3000"), jarClears) // live jar too, not just at rest + assertTrue(stores.watermarks.loadAll().isEmpty()) + } + + @Test + fun `the remote DELETE runs while the host is still paired`() = runTest { + val hosts = RecordingHostStore(listOf(host())) + val push = RecordingPush() + push.probe = { hosts.hosts.isNotEmpty() } + val (remover, _, _) = fixture(hosts = hosts, push = push) + + remover.removeHost("h1", emptyList()) + + assertTrue(push.hostStillPairedAtCallTime == true) + } + + @Test + fun `an unreachable host is still removable`() = runTest { + val push = RecordingPush(error = java.io.IOException("host is off")) + val (remover, _, stores) = fixture(push = push) + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertTrue(stores.hosts.hosts.isEmpty()) // best-effort DELETE must not block the removal + } + + @Test + fun `with no push token the local state is still fully erased`() = runTest { + val push = RecordingPush() + val (remover, _, stores) = fixture(push = push, token = null) + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertTrue(push.calls.isEmpty()) // nothing to DELETE + assertTrue(stores.hosts.hosts.isEmpty()) + assertNull(stores.lastSession.lastSessionId("h1")) + } + + @Test + fun `a failing local erase leaves the host paired so the user can retry`() = runTest { + val hosts = RecordingHostStore(listOf(host())) + hosts.removeError = IllegalStateException("datastore write failed") + val (remover, _, stores) = fixture(hosts = hosts) + + assertThrows(IllegalStateException::class.java) { + kotlinx.coroutines.runBlocking { remover.removeHost("h1", listOf(SESSION_1.toString())) } + } + + assertEquals(1, stores.hosts.hosts.size) // still paired → retryable, never a ghost + } + + @Test + fun `an unknown host id is a no-op that touches nothing`() = runTest { + val push = RecordingPush() + val (remover, _, stores) = fixture(push = push) + + remover.removeHost("nope", listOf(SESSION_1.toString())) + + assertTrue(push.calls.isEmpty()) + assertEquals(1, stores.hosts.hosts.size) + assertFalse(stores.watermarks.loadAll().isEmpty()) // watermarks untouched + } + + @Test + fun `only the named sessions lose their watermarks`() = runTest { + val (remover, _, stores) = fixture() + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertEquals(mapOf(SESSION_2.toString() to 20L), stores.watermarks.loadAll()) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt new file mode 100644 index 0000000..7efb297 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt @@ -0,0 +1,303 @@ +package wang.yaojia.webterm.wiring + +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.DisplayName +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.viewmodels.PairingProber +import wang.yaojia.webterm.viewmodels.PairingUiState +import wang.yaojia.webterm.viewmodels.PairingViewModel +import wang.yaojia.webterm.viewmodels.TokenError +import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * The `WEBTERM_TOKEN` acquisition flow in [PairingViewModel]: prompt → submit → success / wrong / 429. + * + * Lives in the `wiring` test package rather than next to `PairingViewModelTest` because the + * `app/src/test` `viewmodels` package belongs to another agent's file-ownership lane in this run; the + * subject under test is the (public) pairing state machine either way. + * + * Why the flow exists: when a host runs with `WEBTERM_TOKEN` set, `GET /live-sessions` answers **401** + * before anything else can happen (`src/server.ts` `authGate`). The frozen probe taxonomy can only + * report that as `HttpOkButNotWebTerminal` ("wrong port?"), which is actively misleading and leaves a + * token-gated host impossible to pair. So a failed probe asks the prober whether the host is gated, and + * the answer routes to a token prompt instead of the dead-end failure card. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@DisplayName("Pairing — WEBTERM_TOKEN acquisition") +class PairingTokenFlowTest { + + private companion object { + const val TOKEN = "s3cret-shell-token" + const val LAN = "http://10.0.0.5:3000" + const val TUNNEL = "https://star.terminal.yaojia.wang" + } + + /** Records every probe AND every token submission so "no retry / no re-probe" is directly assertable. */ + private class FakeProber( + var probeResult: (HostEndpoint) -> PairingProbeResult = { PairingProbeResult.Success(it) }, + var gated: Boolean = false, + var submission: (String) -> TokenSubmission = { TokenSubmission.Accepted }, + ) : PairingProber { + val probes = mutableListOf() + val submitted = mutableListOf() + + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult { + probes += endpoint + return probeResult(endpoint) + } + + override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = gated + + override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission { + submitted += token + return submission(token) + } + } + + private fun endpoint(url: String): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" } + + private fun gatedThenOpen(prober: FakeProber): (HostEndpoint) -> PairingProbeResult = { ep -> + // Before the token: the gate 401s the reachability read, which the probe can only classify as + // "not a web-terminal". After the token is accepted the same probe succeeds. + if (prober.gated) PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) + else PairingProbeResult.Success(ep) + } + + private fun newVm( + prober: PairingProber, + hasCert: () -> Boolean = { false }, + store: InMemoryHostStore = InMemoryHostStore(), + ) = PairingViewModel( + hostStore = store, + prober = prober, + hasDeviceCert = { hasCert() }, + newId = { "fixed-id" }, + ) + + // ── Prompt ────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a token-gated host asks for the token instead of showing the wrong-port failure`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + val state = vm.uiState.value + assertInstanceOf(PairingUiState.TokenRequired::class.java, state) + assertEquals(endpoint(LAN), (state as PairingUiState.TokenRequired).endpoint) + assertEquals(null, state.error, "the first prompt is not an error") + } + + @Test + fun `an ungated host still shows the wrong-port failure`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber( + probeResult = { PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) }, + gated = false, + ) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value) + } + + @Test + fun `a non-gate failure is never re-interpreted as a token prompt`() = runTest(UnconfinedTestDispatcher()) { + // `gated` is true here, but the failure is a timeout — only the ambiguous + // HttpOkButNotWebTerminal case is allowed to become a token prompt. + val prober = FakeProber(probeResult = { PairingProbeResult.Failure(PairingError.Timeout) }, gated = true) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + val state = vm.uiState.value + assertInstanceOf(PairingUiState.Failed::class.java, state) + assertEquals(PairingError.Timeout, (state as PairingUiState.Failed).error) + } + + // ── Submit ────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `submitting the right token continues pairing and persists the host`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + // Accepting the token means the cookie jar now holds the credential → the gate opens. + prober.submission = { prober.gated = false; TokenSubmission.Accepted } + val store = InMemoryHostStore() + val vm = newVm(prober, store = store) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertEquals(listOf(TOKEN), prober.submitted) + assertEquals(2, prober.probes.size, "an accepted token re-runs the probe") + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + assertEquals(LAN, store.loadAll().single().endpoint.baseUrl) + } + + @Test + fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.Rejected } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken("wrong") + + val state = vm.uiState.value + assertInstanceOf(PairingUiState.TokenRequired::class.java, state) + assertEquals(TokenError.INVALID, (state as PairingUiState.TokenRequired).error) + assertEquals(1, prober.probes.size, "a rejected token must not re-probe") + } + + @Test + fun `a 429 is surfaced honestly and never auto-retried`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.RateLimited } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertEquals(TokenError.RATE_LIMITED, (vm.uiState.value as PairingUiState.TokenRequired).error) + assertEquals(1, prober.submitted.size, "rate-limited must not resubmit by itself") + assertEquals(1, prober.probes.size) + } + + @Test + fun `an unreachable host during submit is distinguishable from a wrong token`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.Unreachable("IOException") } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertEquals(TokenError.UNREACHABLE, (vm.uiState.value as PairingUiState.TokenRequired).error) + } + + @Test + fun `a prober with no token support reports the capability honestly`() = + runTest(UnconfinedTestDispatcher()) { + // The interface defaults exist so a probe-only seam (a test double) still compiles; they must + // surface as an explicit "cannot submit", never as a silent success. + val probeOnly = object : PairingProber { + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult = + PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) + } + val vm = newVm(probeOnly) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + // Not gated by default → the plain failure card, exactly as before this feature existed. + assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value) + } + + @Test + fun `a blank token is never submitted`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(" ") + + assertTrue(prober.submitted.isEmpty()) + assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value) + } + + @Test + fun `a token submitted outside the prompt is a no-op`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber() + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) // still on the confirm gate — nothing has asked for a token + vm.submitAccessToken(TOKEN) + + assertTrue(prober.submitted.isEmpty()) + assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + } + + // ── The token must not outlive the submit ──────────────────────────────────────────────────────── + + @Test + fun `the token is never held in the UI state or any of its toStrings`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.Rejected } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + val promptState = vm.uiState.value + vm.submitAccessToken(TOKEN) + val afterState = vm.uiState.value + + assertFalse(promptState.toString().contains(TOKEN)) + assertFalse(afterState.toString().contains(TOKEN)) + } + + // ── The token path cannot bypass the tunnel cert-gate (RULE 4) ────────────────────────────────── + + @Test + fun `an accepted token still funnels through the device-cert gate`() = + runTest(UnconfinedTestDispatcher()) { + var certInstalled = true + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { prober.gated = false; TokenSubmission.Accepted } + val vm = newVm(prober, hasCert = { certInstalled }) + vm.bind(backgroundScope) + + vm.onManualEntry(TUNNEL) + vm.confirm() + assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value) + + // The cert is removed while the token prompt is open; the post-token probe must re-refuse. + certInstalled = false + vm.submitAccessToken(TOKEN) + + assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value) + assertEquals(1, prober.probes.size, "no network probe without a device cert") + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt index b885e71..1782fbd 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt @@ -1,34 +1,56 @@ package wang.yaojia.webterm.wiring import android.graphics.Bitmap +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TerminalOutput +import com.termux.terminal.TerminalSessionClient import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotSame +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.api.models.LiveSessionInfo import wang.yaojia.webterm.api.models.SessionPreview +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 /** * The [ThumbnailPipeline] policy (plan §6.7), driven at JVM speed with fakes so the cache/concurrency/ - * dedup/failure logic is verified without android.graphics (the pixel raster lives behind the - * [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd). + * dedup/supersede/timeout/failure logic is verified without android.graphics (the pixel raster lives behind + * the [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd). The rasterizer's two + * SIZE decisions are pure ([ThumbnailGrid] / [ThumbnailBudget]) and are tested here too, one of them against + * a real off-screen [TerminalEmulator] (pure Java — the emulator needs no android.graphics). * * Proves: * - an unchanged `(sessionId, lastOutputAt)` ⇒ cache hit, NO re-render; a changed `lastOutputAt` ⇒ render; * - the `Semaphore(2)` caps concurrent fetch+render at 2; * - two concurrent same-key requests SHARE one render (one fetch, one raster, one bitmap); + * - a NEWER key for a session supersedes the older in-flight render and frees its permit; + * - a stalled fetch times out, releases its permit and falls back to the placeholder (liveness); + * - a request after `close()` returns null and leaks no in-flight entry; + * - a render landing after its session was pruned does not re-enter the cache; * - a fetch failure caches the placeholder and is NOT retried on the next request (no hot-loop storm); - * - [PreviewCap] caps the body at 256 KiB, keeping the tail. + * - the off-screen grid never aliases rows, and no server geometry can blow the raster budget; + * - [PreviewCap] caps the emulator's input at 256 KiB, keeping the tail. + * + * **Virtual-time note:** a render is now bounded by [ThumbnailPipeline.DEFAULT_RENDER_TIMEOUT_MS], so + * `advanceUntilIdle()` would advance past that timeout and cancel a deliberately-parked fetch. Tests that + * observe IN-FLIGHT state therefore use `runCurrent()` (run what is ready, advance no virtual time). */ @OptIn(ExperimentalCoroutinesApi::class) class ThumbnailPipelineTest { @@ -59,7 +81,7 @@ class ThumbnailPipelineTest { } /** Hands back a fresh mock bitmap per raster (so identity distinguishes renders) + a fixed placeholder. */ - private class FakeRasterizer : ThumbnailRasterizer { + private class FakeRasterizer(private val placeholderFailure: Throwable? = null) : ThumbnailRasterizer { var rasterizeCount = 0 val placeholderBitmap: Bitmap = mockk() @@ -68,7 +90,10 @@ class ThumbnailPipelineTest { return mockk() } - override fun placeholder(): Bitmap = placeholderBitmap + override fun placeholder(): Bitmap { + placeholderFailure?.let { throw it } + return placeholderBitmap + } } /** Map-backed cache seam — keeps LRU KEYING testable without touching the stubbed android.util.LruCache. */ @@ -78,6 +103,10 @@ class ThumbnailPipelineTest { override fun put(key: ThumbnailKey, bitmap: Bitmap) { map[key] = bitmap } + + override fun retainOnly(keys: Set) { + map.keys.retainAll(keys) + } } private fun session(id: UUID, lastOutputAt: Long?): LiveSessionInfo = LiveSessionInfo( @@ -90,6 +119,32 @@ class ThumbnailPipelineTest { lastOutputAt = lastOutputAt, ) + /** + * A bare off-screen [TerminalEmulator] — the same construction [CanvasThumbnailRasterizer] uses, minus + * the `Canvas`. Termux's emulator/buffer are pure Java (no android.graphics), so the row-mapping + * geometry is verifiable at JVM speed; only the pixel draw is device-QA. + */ + private fun offScreenEmulator(cols: Int, rows: Int, transcriptRows: Int): TerminalEmulator = + TerminalEmulator( + mockk(relaxed = true), + cols, + rows, + transcriptRows, + mockk(relaxed = true), + ) + + private companion object { + /** Short virtual-time render budget so the timeout test doesn't encode the production value. */ + const val TEST_RENDER_TIMEOUT_MS = 1_000L + + /** Bytes per pixel of `Bitmap.Config.ARGB_8888` (the config the rasterizer allocates). */ + const val ARGB_8888_BYTES = 4 + + /** A representative monospace cell at the rasterizer's 12px default (measured on-device). */ + const val TEMPLATE_CELL_WIDTH_PX = 7 + const val TEMPLATE_CELL_HEIGHT_PX = 14 + } + // ── Tests ───────────────────────────────────────────────────────────────────────────────── @Test @@ -135,7 +190,7 @@ class ThumbnailPipelineTest { ) val jobs = (0 until 5).map { launch { pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L)) } } - advanceUntilIdle() + runCurrent() // no virtual time passes — the parked fetches must not hit the render timeout // Only 2 permits → only 2 fetches in flight; the other 3 are parked on the semaphore. assertEquals(2, source.active) @@ -165,7 +220,7 @@ class ThumbnailPipelineTest { val a = async { pipeline.thumbnail(same) } val b = async { pipeline.thumbnail(same) } - advanceUntilIdle() + runCurrent() assertEquals(1, source.fetchStarts) // deduped: a single in-flight fetch gate.complete(Unit) @@ -201,6 +256,401 @@ class ThumbnailPipelineTest { assertEquals(1, source.fetchStarts) } + /** + * A rasterizer failure that ALSO breaks the placeholder (a `Bitmap` OOM is the realistic case) must + * surface as `null`, NOT as an exception — the row's `LaunchedEffect` collects this directly, so a + * throw would take down the composition. It must also leave no poisoned in-flight entry: the next + * request retries instead of re-awaiting a permanently-failed `Deferred`. + */ + @Test + fun `an unrenderable thumbnail returns null and is retried rather than poisoning the key`() = runTest { + val source = FakePreviewSource(failure = RuntimeException("boom")) + val raster = FakeRasterizer(placeholderFailure = OutOfMemoryError("bitmap")) + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val s = session(UUID.randomUUID(), lastOutputAt = 9L) + + val first = async { pipeline.thumbnail(s) } + advanceUntilIdle() + assertNull(first.await()) + assertEquals(1, source.fetchStarts) + + // Nothing cacheable ⇒ the key is free again (no stale in-flight Deferred to re-await forever). + val second = async { pipeline.thumbnail(s) } + advanceUntilIdle() + assertNull(second.await()) + assertEquals(2, source.fetchStarts) + } + + /** + * The render lives in the PIPELINE's scope, not the caller's: a card scrolled off-screen mid-render + * (its collector cancelled) must not cancel the shared render another card is awaiting. + */ + @Test + fun `a cancelled caller does not cancel the shared render`() = runTest { + val gate = CompletableDeferred() + val source = FakePreviewSource(block = gate) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val s = session(UUID.randomUUID(), lastOutputAt = 3L) + + val scrolledAway = launch { pipeline.thumbnail(s) } + runCurrent() + scrolledAway.cancel() + runCurrent() + + val stillVisible = async { pipeline.thumbnail(s) } + runCurrent() + gate.complete(Unit) + advanceUntilIdle() + + assertNotNull(stillVisible.await()) + assertEquals(1, source.fetchStarts) // the first render survived the cancelled caller + assertEquals(1, raster.rasterizeCount) + } + + /** + * Bitmaps must not be retained for sessions that are gone (killed/exited) or for superseded + * `lastOutputAt` keys: [ThumbnailPipeline.retainOnly] prunes everything outside the current live list, + * so a still-live session keeps its cache hit while a dead one's bitmap is released. + */ + @Test + fun `retainOnly releases bitmaps for sessions no longer live`() = runTest { + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = FakePreviewSource(), + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val live = session(UUID.randomUUID(), lastOutputAt = 1L) + val dead = session(UUID.randomUUID(), lastOutputAt = 1L) + + val warm = launch { + pipeline.thumbnail(live) + pipeline.thumbnail(dead) + } + advanceUntilIdle() + assertTrue(warm.isCompleted) + assertEquals(2, raster.rasterizeCount) + + pipeline.retainOnly(listOf(live)) + + // The live session is still a cache hit … + val hit = async { pipeline.thumbnail(live) } + advanceUntilIdle() + hit.await() + assertEquals(2, raster.rasterizeCount) + + // … the dead one's bitmap was released, so asking again re-renders (it is no longer retained). + val miss = async { pipeline.thumbnail(dead) } + advanceUntilIdle() + miss.await() + assertEquals(3, raster.rasterizeCount) + } + + /** + * **Supersede (head-of-line blocking).** `lastOutputAt` advances on every PTY output and the list polls + * every 5 s, so an ACTIVE session mints a new key on every tick. The obsolete render must be abandoned: + * left alone it holds a permit while the row waits for the screen it is actually showing, and its bitmap + * would land in the cache under a key nothing will ever ask for again. + */ + @Test + fun `a newer key supersedes the in-flight render for the same session and frees its permit`() = runTest { + val stall = CompletableDeferred() + val source = FakePreviewSource(block = stall) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + maxConcurrent = 1, // ONE permit: whether the obsolete render blocks the fresh one is the point + ) + val id = UUID.randomUUID() + + val stale = async { pipeline.thumbnail(session(id, lastOutputAt = 100L)) } + runCurrent() + assertEquals(1, source.fetchStarts) + assertEquals(1, source.active) // holds the only permit + + val fresh = async { pipeline.thumbnail(session(id, lastOutputAt = 200L)) } + runCurrent() + assertEquals(2, source.fetchStarts) // the freed permit let the NEWEST key start + assertEquals(1, source.active) // and only one render is alive — the obsolete one was abandoned + + stall.complete(Unit) + advanceUntilIdle() + + assertNull(stale.await()) // the superseded caller gets no bitmap; its screen no longer exists + assertNotNull(fresh.await()) + assertEquals(1, raster.rasterizeCount) // only the newest key was ever rasterised + } + + /** + * **Liveness.** The shared REST client sets no `callTimeout` and its per-socket read timeout is reset by + * every trickled byte, and a render is deliberately not cancellable by any caller — so without a bound + * inside the render, two slow-trickle previews hold both permits forever and NO thumbnail renders again + * until `close()`. The timeout raises `TimeoutCancellationException`, which IS a `CancellationException`: + * it must land in the PLACEHOLDER branch, never the rethrow branch. + */ + @Test + fun `a stalled fetch times out, frees its permit and falls back to the placeholder`() = runTest { + val stall = CompletableDeferred() // never completed: a host that trickles and never finishes + val source = FakePreviewSource(block = stall) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + renderTimeoutMs = TEST_RENDER_TIMEOUT_MS, + ) + + val stalled = (0 until 2).map { async { pipeline.thumbnail(session(UUID.randomUUID(), 1L)) } } + runCurrent() + assertEquals(2, source.active) // both permits held by fetches that will never finish + + val queued = async { pipeline.thumbnail(session(UUID.randomUUID(), 1L)) } + runCurrent() + assertEquals(2, source.fetchStarts) // parked on the semaphore behind the stalled pair + + advanceTimeBy(TEST_RENDER_TIMEOUT_MS + 1) + runCurrent() + + // Both stalled renders gave up with the placeholder (cached ⇒ no retry storm) instead of dying or + // wedging, and — the whole point — the queued key got a permit and ran. + stalled.forEach { assertSame(raster.placeholderBitmap, it.await()) } + assertEquals(3, source.fetchStarts) + + advanceTimeBy(TEST_RENDER_TIMEOUT_MS + 1) + runCurrent() + assertSame(raster.placeholderBitmap, queued.await()) + assertEquals(0, source.active) + pipeline.close() + } + + /** + * `close()` is public and unguarded, and `scope.async` on a cancelled scope never runs the body — so the + * body's `finally` (which frees the in-flight slot) never runs either. Inserting after close would leave + * a dead `Deferred` under that key forever: every later request for it awaits a corpse and gets null, + * and `inFlight` grows by every distinct post-close key. + */ + @Test + fun `a request after close returns null and leaks no in-flight entry`() = runTest { + val source = FakePreviewSource() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = FakeRasterizer(), + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + pipeline.close() + + val afterClose = pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L)) + advanceUntilIdle() + + assertNull(afterClose) + assertEquals(0, source.fetchStarts) // nothing was fetched on a dead pipeline … + assertEquals(0, pipeline.inFlightCount()) // … and no key was left holding a dead Deferred + } + + /** + * The prune is defeatable by a render that finishes AFTER it: re-inserting a killed session's bitmap + * leaves it cached until the row set changes again. The caller that asked still gets its bitmap (it + * asked), but nothing outlives the session in the cache. + */ + @Test + fun `a render landing after its session was pruned is not put back in the cache`() = runTest { + val stall = CompletableDeferred() + val source = FakePreviewSource(block = stall) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val killed = session(UUID.randomUUID(), lastOutputAt = 4L) + + val landing = async { pipeline.thumbnail(killed) } + runCurrent() + assertEquals(1, source.fetchStarts) + + pipeline.retainOnly(emptyList()) // the session was killed while its render was in flight + stall.complete(Unit) + advanceUntilIdle() + assertNotNull(landing.await()) + + // Nothing was cached for the dead session, so asking again re-fetches and re-renders. + val again = async { pipeline.thumbnail(killed) } + advanceUntilIdle() + again.await() + assertEquals(2, source.fetchStarts) + assertEquals(2, raster.rasterizeCount) + } + + // ── Off-screen grid + raster budget (pure; the real emulator is pure Java) ───────────────── + + /** + * **The thumbnail must not draw duplicated rows.** Termux's `TerminalBuffer` keeps + * `mTotalRows = transcriptRows` and maps `externalToInternalRow(row) = (mScreenFirstRow + row) % + * mTotalRows`, so a transcript SHORTER than the screen aliases external row *n* onto *n − mTotalRows*. + * The off-screen emulator used a fixed `TERMINAL_TRANSCRIPT_ROWS_MIN` (100) against rows up to 200, + * so rows 100..199 aliased onto 0..99 — asserted below both ways: the fixed grid is injective, and the + * old geometry provably is not (so this regression cannot quietly come back as "no longer reproducible"). + */ + @Test + fun `the off-screen grid never aliases screen rows onto each other`() { + val grid = ThumbnailGrid.of(cols = 120, rows = 200) + assertEquals(120, grid.cols) + assertEquals(200, grid.rows) + assertTrue( + grid.transcriptRows >= grid.rows, + "transcriptRows=${grid.transcriptRows} must be >= rows=${grid.rows}", + ) + + val fixed = offScreenEmulator(grid.cols, grid.rows, grid.transcriptRows) + val fixedRows = (0 until grid.rows).map { fixed.screen.externalToInternalRow(it) } + assertEquals(grid.rows, fixedRows.toSet().size, "external rows must map 1:1 onto internal rows") + + val aliased = offScreenEmulator(grid.cols, grid.rows, TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN) + val aliasedRows = (0 until grid.rows).map { aliased.screen.externalToInternalRow(it) } + assertTrue( + aliasedRows.toSet().size < grid.rows, + "the aliasing this guards against is no longer reproducible — re-derive the guard", + ) + } + + @Test + fun `the off-screen grid clamps a rogue server geometry and never degenerates`() { + // cols/rows are SERVER-supplied: src/protocol.ts validates a resize only to 1..1000. + val rogue = ThumbnailGrid.of(cols = 1000, rows = 1000) + assertEquals(ThumbnailGrid.MAX_COLS, rogue.cols) + assertEquals(ThumbnailGrid.MAX_ROWS, rogue.rows) + assertTrue(rogue.transcriptRows >= rogue.rows) + + val degenerate = ThumbnailGrid.of(cols = 0, rows = -5) + assertEquals(1, degenerate.cols) + assertEquals(1, degenerate.rows) + assertTrue(degenerate.transcriptRows >= TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN) + } + + /** + * **No server-reported geometry may drive a huge allocation.** A 1000×1000 session (which the protocol + * permits) at a 12px monospace cell was a 2800×2800 ARGB_8888 = 31.4 MB bitmap — ×2 concurrent permits, + * re-run every 5 s poll tick. It degraded safely (an OOM is caught) but invited an LMK kill of the app. + */ + @Test + fun `no server geometry can make the off-screen raster exceed the budget`() { + val budgetBytes = ThumbnailBudget.MAX_WIDTH_PX.toLong() * + ThumbnailBudget.MAX_HEIGHT_PX * ARGB_8888_BYTES + for (cols in intArrayOf(1, 20, 80, 161, 400, 1000)) { + for (rows in intArrayOf(1, 24, 50, 200, 1000)) { + val grid = ThumbnailGrid.of(cols, rows) + val cell = ThumbnailBudget.cellMetrics( + cols = grid.cols, + rows = grid.rows, + templateWidth = TEMPLATE_CELL_WIDTH_PX, + templateHeight = TEMPLATE_CELL_HEIGHT_PX, + ) + val width = grid.cols * cell.width + val height = grid.rows * cell.height + val where = "$cols×$rows → ${width}×$height px (cell ${cell.width}×${cell.height})" + assertTrue(cell.width >= 1 && cell.height >= 1, "zero-sized cell: $where") + assertTrue(width <= ThumbnailBudget.MAX_WIDTH_PX, "width over budget: $where") + assertTrue(height <= ThumbnailBudget.MAX_HEIGHT_PX, "height over budget: $where") + assertTrue(width.toLong() * height * ARGB_8888_BYTES <= budgetBytes, "bytes over budget: $where") + } + } + } + + @Test + fun `the cell shrink preserves the cell aspect and never upscales a small screen`() { + // 80×24 at a 7×14 cell is 560×336 — just over the 480px budget, so both axes take the SAME factor + // (480/560) and the glyph cell keeps its shape instead of being squeezed on one axis. + val shrunk = ThumbnailBudget.cellMetrics(cols = 80, rows = 24, templateWidth = 7, templateHeight = 14) + assertEquals(6, shrunk.width) + assertEquals(12, shrunk.height) + + // Already inside the budget → draw at the font's own cell size, no shrink at all. + val asRendered = ThumbnailBudget.cellMetrics(cols = 40, rows = 10, templateWidth = 7, templateHeight = 14) + assertEquals(7, asRendered.width) + assertEquals(14, asRendered.height) + assertEquals(1f, asRendered.textScale) + } + + @Test + fun `raster budget downscales an oversized terminal raster and leaves small ones alone`() { + // A 161x50 session at 12px monospace rasterises to ~1127x700 = 3.1 MiB ARGB_8888 — three of those + // blow an 8 MiB cache. The budget scales it to the card's resolution, preserving aspect ratio. + val scaled = ThumbnailBudget.scaledSize(width = 1127, height = 700, maxWidth = 480) + assertNotNull(scaled) + assertEquals(480, scaled!!.width) + assertEquals(298, scaled.height) // 700 * 480 / 1127, floored + + // Already inside the budget → no scale step at all (null = draw as rendered). + assertNull(ThumbnailBudget.scaledSize(width = 320, height = 200, maxWidth = 480)) + + // A degenerate ratio never collapses to a zero-height bitmap. + val sliver = ThumbnailBudget.scaledSize(width = 4000, height = 3, maxWidth = 480) + assertNotNull(sliver) + assertEquals(1, sliver!!.height) + + // HEIGHT is bounded too: a tall/narrow raster is already inside the width bound, so a width-only + // budget left it at full size (140×2800 = 1.5 MiB cached for a 96×60dp tile). + val tall = ThumbnailBudget.scaledSize(width = 140, height = 2800, maxWidth = 480, maxHeight = 480) + assertNotNull(tall) + assertEquals(480, tall!!.height) + assertEquals(24, tall.width) // aspect preserved on the tighter axis + } + + /** + * **A thumbnail fetch must NEVER attach.** Looking at the home chooser reads the ring-buffer tail over + * the read-only `GET /live-sessions/:id/preview` route ONLY — it must not open a WS, must not register + * a client, and must not touch the session's idle clock, or merely displaying the grid would keep every + * session alive forever and defeat the idle-reclaim design (plan §5.2 / §6.7). + * + * Asserted at the transport: exactly ONE request, `GET`, on the preview path, carrying **no `Origin` + * header** (which is the marker of a guarded/state-changing route — plan §4.2/§4.3). + */ + @Test + fun `the production preview source issues only the read-only preview GET and never attaches`() = runTest { + val transport = FakeHttpTransport() + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.7:3000")) + val id = UUID.fromString("11111111-2222-4333-8444-555555555555") + val previewUrl = "http://10.0.0.7:3000/live-sessions/$id/preview" + transport.queueSuccess( + method = HttpMethod.GET, + url = previewUrl, + body = """{"id":"$id","cols":80,"rows":24,"data":"hello"}""".toByteArray(Charsets.UTF_8), + ) + + val fetched = ApiPreviewSource { ApiClient(endpoint = endpoint, http = transport) }.fetch(id) + + assertEquals("hello", fetched.data) + assertEquals(1, transport.recordedRequests.size) + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.GET, request.method) + assertEquals(previewUrl, request.url) + assertNull(request.body) + assertNull(request.headers["Origin"], "the RO preview route must not carry Origin: ${request.headers}") + } + + /** + * [PreviewCap] bounds what reaches the EMULATOR, not the response body (which the transport has already + * buffered and parsed by then — see its KDoc). 256 KiB is the client's own ceiling: the server's + * `PREVIEW_BYTES` default is 24 KiB and an operator may raise it with no upper bound. + */ @Test fun `preview cap keeps the tail when over 256 KiB and is a no-op under the cap`() { val big = "A".repeat(PreviewCap.MAX_PREVIEW_BYTES + 100) + "TAIL" diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/UiConfigGateTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/UiConfigGateTest.kt new file mode 100644 index 0000000..104b673 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/UiConfigGateTest.kt @@ -0,0 +1,85 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.CancellationException +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.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.UiConfig +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * [UiConfigGate] — the client half of `GET /config/ui`'s `allowAutoMode`. + * + * `ApiClient.uiConfig()` was implemented and never called, so the plan-gate sheet offered + * "approve + auto-accept edits" even on a host whose operator had switched `ALLOW_AUTO_MODE` off. The + * gate closes that, and the ONE rule that matters is that **failure fails CLOSED**: an unreachable host, + * a pre-`/config/ui` server, or a garbled body must all mean "auto is not allowed", because the + * permissive default is the dangerous one. + */ +class UiConfigGateTest { + + private companion object { + val HOST_A: HostEndpoint = HostEndpoint.fromBaseUrl("http://a:3000")!! + val HOST_B: HostEndpoint = HostEndpoint.fromBaseUrl("http://b:3000")!! + } + + @Test + fun `auto mode is allowed when the host says so`() = runTest { + var calls = 0 + val gate = UiConfigGate { calls += 1; UiConfig(allowAutoMode = true) } + + assertTrue(gate.allowsAutoMode(HOST_A)) + assertEquals(1, calls) + } + + @Test + fun `auto mode is refused when the host disabled it`() = runTest { + val gate = UiConfigGate { UiConfig(allowAutoMode = false) } + assertFalse(gate.allowsAutoMode(HOST_A)) + } + + @Test + fun `a failed fetch fails CLOSED`() = runTest { + val gate = UiConfigGate { throw java.io.IOException("host unreachable") } + assertFalse(gate.allowsAutoMode(HOST_A)) + } + + @Test + fun `a successful answer is cached per host`() = runTest { + val seen = mutableListOf() + val gate = UiConfigGate { endpoint -> + seen += endpoint.baseUrl + UiConfig(allowAutoMode = endpoint == HOST_A) + } + + assertTrue(gate.allowsAutoMode(HOST_A)) + assertTrue(gate.allowsAutoMode(HOST_A)) // served from cache + assertFalse(gate.allowsAutoMode(HOST_B)) // a DIFFERENT host is fetched, not inherited + + assertEquals(listOf(HOST_A.baseUrl, HOST_B.baseUrl), seen.toList()) + } + + @Test + fun `a failure is NOT cached so a host that comes back is re-asked`() = runTest { + var attempt = 0 + val gate = UiConfigGate { + attempt += 1 + if (attempt == 1) throw java.io.IOException("down") else UiConfig(allowAutoMode = true) + } + + assertFalse(gate.allowsAutoMode(HOST_A)) // fail closed + assertTrue(gate.allowsAutoMode(HOST_A)) // recovered → now allowed + assertEquals(2, attempt) + } + + @Test + fun `cancellation propagates instead of being read as a policy answer`() = runTest { + val gate = UiConfigGate { throw CancellationException("scope cancelled") } + assertThrows(CancellationException::class.java) { + kotlinx.coroutines.runBlocking { gate.allowsAutoMode(HOST_A) } + } + } +} diff --git a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipherTest.kt b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipherTest.kt new file mode 100644 index 0000000..d1719d5 --- /dev/null +++ b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipherTest.kt @@ -0,0 +1,129 @@ +package wang.yaojia.webterm.tlsandroid + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.security.GeneralSecurityException +import java.util.UUID + +/** + * Instrumented tests for [TinkAuthCookieCipher] — the at-rest encryption `:host-registry` seals the + * `webterm_auth` shell credential with. Real `AndroidKeyStore` + real Tink, NOT Robolectric (plan §7), + * so these COMPILE in the build env and RUN during device QA. + * + * Each test uses a fresh keyset/pref/master-key namespace so a leftover key from an earlier run can + * never make a test pass or fail spuriously. + */ +@RunWith(AndroidJUnit4::class) +class TinkAuthCookieCipherTest { + + private lateinit var context: Context + private lateinit var namespace: String + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + namespace = UUID.randomUUID().toString().take(8) + } + + private fun newCipher(suffix: String = "a") = TinkAuthCookieCipher( + context = context, + keysetName = "test_cookie_keyset_${namespace}_$suffix", + prefFileName = "test_cookie_prefs_${namespace}_$suffix", + masterKeyUri = "android-keystore://test_cookie_master_${namespace}_$suffix", + ) + + @Test + fun sealed_text_round_trips_and_hides_the_plaintext() { + // Arrange + val cipher = newCipher() + + // Act + val sealed = cipher.seal(PLAINTEXT) + + // Assert: real encryption, not an encoding — and it opens back exactly. + assertNotEquals(PLAINTEXT, sealed) + assertFalse("the plaintext survived into the ciphertext", sealed.contains(SECRET)) + assertEquals(PLAINTEXT, cipher.open(sealed)) + } + + @Test + fun a_new_cipher_over_the_same_keyset_opens_what_the_previous_one_sealed() { + // Arrange: the cold-start path — process restart, same AndroidKeyStore master key. + val sealed = newCipher().seal(PLAINTEXT) + + // Act + Assert + assertEquals(PLAINTEXT, newCipher().open(sealed)) + } + + @Test + fun sealing_the_same_text_twice_yields_different_ciphertext() { + // AEAD is randomised (fresh IV), so equal cookies do not produce an equal blob — otherwise an + // observer of the file could tell "the credential did not change" across writes. + val cipher = newCipher() + + assertNotEquals(cipher.seal(PLAINTEXT), cipher.seal(PLAINTEXT)) + } + + @Test + fun a_tampered_blob_is_rejected_rather_than_half_decrypted() { + // Arrange + val cipher = newCipher() + val sealed = cipher.seal(PLAINTEXT) + val flipped = sealed.mutateOneChar() + + // Act + Assert: authenticated encryption → throws, never returns attacker-shaped plaintext. + assertThrows(GeneralSecurityException::class.java) { cipher.open(flipped) } + } + + @Test + fun a_blob_that_is_not_base64_is_rejected_without_echoing_it() { + val cipher = newCipher() + + val error = assertThrows(GeneralSecurityException::class.java) { cipher.open("!!! not base64 !!!") } + + assertFalse( + "an error message must never echo the stored blob", + error.message.orEmpty().contains("not base64 !!!"), + ) + } + + @Test + fun another_namespaces_key_cannot_open_this_blob() { + // Arrange: two ciphers = two keysets under two master keys (the cert store is a third). + val sealed = newCipher("a").seal(PLAINTEXT) + + // Act + Assert: key separation is real, so one at-rest namespace cannot read another's. + assertThrows(GeneralSecurityException::class.java) { newCipher("b").open(sealed) } + } + + @Test + fun toString_never_reveals_the_credential() { + val cipher = newCipher() + cipher.seal(PLAINTEXT) + + assertFalse(cipher.toString().contains(SECRET)) + } + + /** Flip one character of the base64 body so the AEAD tag no longer matches. */ + private fun String.mutateOneChar(): String { + val index = length / 2 + val replacement = if (this[index] == 'A') 'B' else 'A' + return substring(0, index) + replacement + substring(index + 1) + } + + private companion object { + const val SECRET = "SUPER_SECRET_SHELL_TOKEN_9f3a7c11" + + /** Shaped like what the store actually seals: the serialized cookie list. */ + const val PLAINTEXT = + """[{"hostKey":"http://10.0.0.1:3000","name":"webterm_auth","value":"$SECRET"}]""" + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt index 6230355..67551d8 100644 --- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt @@ -43,6 +43,17 @@ public class DeviceEnroller( // AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no // process restart. Optional so the JVM orchestration tests can construct the enroller without it. private val cacheRefresher: IdentityCacheRefresher? = null, + /** + * A SECOND client over a PLAIN (non-mTLS) transport, used ONLY by [recover]. It must not be the + * same client as [client]: an expired leaf cannot authenticate its own re-issuance, and an mTLS + * transport would present that expired leaf, which nginx refuses to forward at all (bare 400). + * Null ⇒ recovery is unavailable and [recover] throws rather than falling back to mTLS. + * + * It MUST target the same control plane as [client] — the enrollment record stores [client]'s + * base URL as the rotation target, so a recovery pointed elsewhere would rewrite the identity + * while leaving the record naming the original host. + */ + private val recoveryClient: DeviceEnrollmentClient? = null, ) { private val commitMutex = Mutex() @@ -97,6 +108,40 @@ public class DeviceEnroller( summaryOf(result) } + /** + * Recover an EXPIRED leaf: re-CSR from the SAME hardware key and POST it together with the lapsed + * certificate to `POST /device/:id/recover` over the PLAIN (non-mTLS) [recoveryClient]. + * + * This exists because `/device/:id/renew` is authenticated by the very certificate it renews, so a + * lapsed leaf deadlocks: no TLS terminator will even forward an expired client certificate (nginx + * answers a bare 400 under `ssl_verify_client optional`, and `optional_no_ca` tolerates only CHAIN + * errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). Without this call an expired device certificate + * means a full manual re-enroll. Possession is still proven — the CSR is self-signed by the same + * non-exportable key and the control plane enforces CSR proof-of-possession plus + * `CSR key == registered key` — and revocation, account/`:id` consistency and `notBefore` are all + * still enforced server-side; only `notAfter` is graced (30 days). + * + * Failure posture: everything before [commitIdentity]'s cert-store save is read-only, so a rejected + * or unreachable recovery leaves the prior identity and its key exactly as they were. + * + * Throws [EnrollmentStateException] when there is no [recoveryClient], no enrollment record, no + * stored certificate to present, or no hardware key — none of which a request could fix. + */ + public suspend fun recover(): CertificateSummary = commitMutex.withLock { + val recovery = recoveryClient + ?: throw EnrollmentStateException("no plain-HTTPS recovery client — refusing to recover over mTLS") + val record = recordStore.load() + ?: throw EnrollmentStateException("no enrollment record — nothing to recover") + val expiredLeaf = certStore.load()?.leafCertificate + ?: throw EnrollmentStateException("no stored device certificate — a fresh enroll is required") + val key = keyProvider.load(record.keyStoreAlias) + ?: throw EnrollmentStateException("device key missing — a fresh enroll is required") + val csr = CertificateSigningRequest.der(record.deviceName, key) + val result = recovery.recover(record.deviceId, expiredLeaf.encoded, csr) + commitIdentity(result, record.deviceName, record.keyStoreAlias) + summaryOf(result) + } + /** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */ public suspend fun remove(): Unit = commitMutex.withLock { certStore.clear() @@ -120,7 +165,13 @@ public class DeviceEnroller( deviceId = result.deviceId, deviceName = deviceName, keyStoreAlias = alias, + // Only an enroll 201 carries renewAfter; a renew/recover 201 does not, so this is 0 + // after a rotation. That is fine — DeviceRotationStore derives the live timing from + // the leaf certificate, never from this advisory field. renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L, + // Persist WHICH control plane issued this identity so silent rotation (which has no UI + // to ask on) renews against the same one instead of a compiled-in default. + controlPlaneUrl = client.baseUrl, ), ) certStore.save( diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStore.kt new file mode 100644 index 0000000..d07e424 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStore.kt @@ -0,0 +1,60 @@ +package wang.yaojia.webterm.tlsandroid + +import wang.yaojia.webterm.api.enroll.DeviceRenewalState +import wang.yaojia.webterm.api.enroll.RotationPolicy + +/** + * Everything a rotation pass needs to know, read from storage: the pure [renewalState] the decision + * table consumes plus [controlPlaneUrl], the host to rotate against. + * + * Carries no credential: only the non-secret device id, the leaf's timing, and a URL. Safe to log. + */ +public data class DeviceRotationSnapshot( + val renewalState: DeviceRenewalState, + val controlPlaneUrl: String, +) + +/** + * The READ side of silent certificate rotation — the Android counterpart of iOS + * `KeychainClientIdentityStore.renewalState()`, composing the two persisted stores into one snapshot. + * + * ### Why the timing comes from the LEAF, not the record + * `POST /device/:id/renew` and `POST /device/:id/recover` answer `{cert, caChain, notAfter}` — neither + * returns a `renewAfter` (only `/device/enroll` does). A driver that trusted + * [EnrollmentRecord.renewAfterEpochSeconds] would therefore see "unknown" after the very first + * rotation and report not-due until the certificate died — exactly once-and-never-again rotation. The + * live leaf's own `notBefore`/`notAfter` are always present and are what the TLS stack actually + * enforces, so they are the source of truth; [RotationPolicy.renewAfterFor] re-derives the advisory + * instant from them with the control plane's own 2/3-of-lifetime fraction. + * + * Deliberately I/O-only and framework-free (no `android.*` import), so the whole read path is JVM + * unit-tested. A store fault PROPAGATES rather than degrading to "nothing enrolled": silently + * reporting not-enrolled would disable rotation forever, whereas a surfaced failure is retried. + */ +public class DeviceRotationStore( + private val certStore: CertStore, + private val recordStore: EnrollmentRecordStore, +) { + /** + * The current rotation snapshot, or null when there is nothing to rotate — no enrollment record + * (fresh install / legacy `.p12`-only identity) or no live certificate (a renew has nothing to + * re-CSR for and a recovery has no lapsed leaf to present, so only a fresh enroll can help). + * + * Performs AndroidKeyStore/Tink-backed reads via the injected stores: call it OFF the main thread. + */ + public fun read(): DeviceRotationSnapshot? { + val record = recordStore.load() ?: return null + val leaf = certStore.load()?.leafCertificate ?: return null + return DeviceRotationSnapshot( + renewalState = DeviceRenewalState( + deviceId = record.deviceId, + notAfter = leaf.notAfter.toInstant(), + renewAfter = RotationPolicy.renewAfterFor( + notBefore = leaf.notBefore.toInstant(), + notAfter = leaf.notAfter.toInstant(), + ), + ), + controlPlaneUrl = record.controlPlaneUrl, + ) + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt index d027574..119ab96 100644 --- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt @@ -4,20 +4,27 @@ import android.content.Context import android.content.SharedPreferences import android.util.Base64 import com.google.crypto.tink.Aead -import com.google.crypto.tink.KeyTemplates -import com.google.crypto.tink.RegistryConfiguration -import com.google.crypto.tink.aead.AeadConfig -import com.google.crypto.tink.integration.android.AndroidKeysetManager import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream +import java.io.EOFException /** * B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted * [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR * subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign - * with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler. + * with, [renewAfterEpochSeconds] (0 = unknown) as issued at enroll time, and [controlPlaneUrl] — + * WHICH control plane to rotate against. + * + * [controlPlaneUrl] exists because rotation is silent: there is no screen to re-type the URL on, and + * falling back to a compiled-in default would renew against the wrong host. It is not a credential + * (iOS keeps the same value in plain `UserDefaults`); a record written before the field existed + * decodes to `""`, which the rotation driver treats as "cannot rotate" rather than guessing. + * + * [renewAfterEpochSeconds] is only ever ADVISORY and only ever set by an enroll: neither + * `/device/:id/renew` nor `/device/:id/recover` returns a `renewAfter`. The live timing therefore + * comes from the leaf certificate itself — see [DeviceRotationStore]. * * This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert * identity is what the handshake presents; this record only exists so renew can find the device and @@ -28,6 +35,8 @@ public data class EnrollmentRecord( val deviceName: String, val keyStoreAlias: String, val renewAfterEpochSeconds: Long, + /** Control-plane base URL this device enrolled against; `""` when unknown (a legacy record). */ + val controlPlaneUrl: String = "", ) { init { require(deviceId.isNotBlank()) { "deviceId must not be blank" } @@ -35,7 +44,7 @@ public data class EnrollmentRecord( } } -/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */ +/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — four UTF strings + one long). */ public object EnrollmentRecordCodec { public fun encode(record: EnrollmentRecord): ByteArray { val out = ByteArrayOutputStream() @@ -44,6 +53,7 @@ public object EnrollmentRecordCodec { data.writeUTF(record.deviceName) data.writeUTF(record.keyStoreAlias) data.writeLong(record.renewAfterEpochSeconds) + data.writeUTF(record.controlPlaneUrl) } return out.toByteArray() } @@ -57,11 +67,25 @@ public object EnrollmentRecordCodec { deviceName = data.readUTF(), keyStoreAlias = data.readUTF(), renewAfterEpochSeconds = data.readLong(), + controlPlaneUrl = readTrailingUtfOrBlank(data), ) } } catch (e: Exception) { throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e) } + + /** + * Read the trailing [EnrollmentRecord.controlPlaneUrl], tolerating its ABSENCE (a blob written + * before the field existed) as `""`. Only end-of-blob is tolerated: a record that ends exactly + * after the long is a valid older layout, whereas a mangled one still fails the reads above and is + * reported corrupt. Declaring an existing enrollment corrupt would strand a working device. + */ + private fun readTrailingUtfOrBlank(data: DataInputStream): String = + try { + data.readUTF() + } catch (_: EOFException) { + "" + } } /** @@ -118,16 +142,8 @@ public class TinkEnrollmentRecordStore( if (!committed) throw java.io.IOException("Failed to durably clear the device enrollment record") } - private fun buildAead(): Aead { - AeadConfig.register() - val keysetHandle = AndroidKeysetManager.Builder() - .withSharedPref(appContext, keysetName, prefFileName) - .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE)) - .withMasterKeyUri(masterKeyUri) - .build() - .keysetHandle - return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) - } + private fun buildAead(): Aead = + buildAndroidKeystoreAead(appContext, keysetName, prefFileName, masterKeyUri) private fun prefs(): SharedPreferences = appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE) @@ -136,7 +152,6 @@ public class TinkEnrollmentRecordStore( private const val DEFAULT_KEYSET_NAME = "webterm_enroll_keyset" private const val DEFAULT_PREF_FILE = "webterm_enroll_record_prefs" private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_enroll_master_key" - private const val AEAD_KEY_TEMPLATE = "AES256_GCM" private const val BLOB_KEY = "enrollment_record_blob" private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.enrollment-record".toByteArray() } diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAead.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAead.kt new file mode 100644 index 0000000..ae015ce --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAead.kt @@ -0,0 +1,43 @@ +package wang.yaojia.webterm.tlsandroid + +import android.content.Context +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.RegistryConfiguration +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.integration.android.AndroidKeysetManager + +/** The one AEAD primitive every at-rest blob in this module is encrypted with (plan §2/§8). */ +private const val AEAD_KEY_TEMPLATE = "AES256_GCM" + +/** + * Builds the Tink [Aead] for one at-rest namespace: the keyset lives in the app-private + * `SharedPreferences` file [prefFileName] under [keysetName] and is itself encrypted by the + * `AndroidKeyStore` master key [masterKeyUri], so the on-disk keyset is useless off this device. + * + * Shared by every store here ([TinkCertStore], [TinkEnrollmentRecordStore], [TinkAuthCookieCipher]) + * so the custody model is defined ONCE — a weaker key template or a forgotten master-key wrapping + * cannot drift into one store while the others stay hardened. Each caller passes its OWN + * keyset/file/master-key names: same mechanism, separate keys, so one namespace's blob can never be + * opened with another's key. + * + * Not cached — callers hold the result in a `by lazy`, which keeps the (synchronous, disk- and + * keystore-touching) build off the first-use path's critical section and off `Dispatchers.Main`. + * + * @throws java.security.GeneralSecurityException if the keyset cannot be created or unwrapped. + */ +internal fun buildAndroidKeystoreAead( + appContext: Context, + keysetName: String, + prefFileName: String, + masterKeyUri: String, +): Aead { + AeadConfig.register() + val keysetHandle = AndroidKeysetManager.Builder() + .withSharedPref(appContext, keysetName, prefFileName) + .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE)) + .withMasterKeyUri(masterKeyUri) + .build() + .keysetHandle + return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipher.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipher.kt new file mode 100644 index 0000000..c735001 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipher.kt @@ -0,0 +1,97 @@ +package wang.yaojia.webterm.tlsandroid + +import android.content.Context +import android.util.Base64 +import com.google.crypto.tink.Aead +import java.security.GeneralSecurityException + +/** + * At-rest encryption for the persisted `webterm_auth` session cookie — the same custody model as + * [TinkCertStore] (Tink AEAD under an `AndroidKeyStore`-wrapped master key), applied to the credential + * that is strictly MORE powerful than the cert chain: it grants a full shell for the server's 30-day + * `Max-Age` (`src/http/auth.ts`), so plan §8's "encrypted, app-private, never in a backup" rule binds + * here at least as hard. + * + * ## Why this class does not implement `:host-registry`'s `AuthCookieCipher` + * It matches that interface's shape EXACTLY ([seal] / [open]) but does not declare it: this module + * must not depend on `:host-registry` (siblings under `:app` — `android/README.md`: *dependencies only + * flow down; nothing points upward*), and `:host-registry` must not depend on Tink. `:app` owns the + * 4-line adapter, which is the same bridge pattern A15 already uses for `:transport-okhttp`'s + * `ClientIdentityProvider`: + * + * ```kotlin + * val tink = TinkAuthCookieCipher(context) + * val cipher = object : AuthCookieCipher { + * override fun seal(plaintext: String): String = tink.seal(plaintext) + * override fun open(sealed: String): String = tink.open(sealed) + * } + * ``` + * + * ## Custody + * - Own keyset, own pref file, own master key and own AEAD associated data — a cookie blob can never + * be opened with the cert store's key, nor swapped into its slot (and vice versa). + * - The master key never leaves `AndroidKeyStore`, so the ciphertext is useless off this device even + * if the app-private file is copied out. + * - Nothing here is ever logged and [toString] cannot reveal the plaintext; a decrypt failure carries + * only the reason, never the payload (`AuthCookieCipher`'s contract). + * + * Both operations throw on failure — the caller (`DataStoreAuthCookieStore`) then fails CLOSED, + * persisting nothing rather than falling back to plaintext. + * + * @param context any Context; only `applicationContext` is retained. + */ +public class TinkAuthCookieCipher( + context: Context, + private val keysetName: String = DEFAULT_KEYSET_NAME, + private val prefFileName: String = DEFAULT_PREF_FILE, + private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI, +) { + private val appContext: Context = context.applicationContext + + // Built on first use: registers AEAD, loads-or-creates the AndroidKeystore-wrapped keyset. + private val aead: Aead by lazy { + buildAndroidKeystoreAead(appContext, keysetName, prefFileName, masterKeyUri) + } + + /** + * Encrypt [plaintext] into a Base64 blob safe to store in (plaintext) Preferences DataStore. + * + * @throws GeneralSecurityException if the keyset/master key is unavailable — the caller then + * persists NOTHING (fail closed). + */ + public fun seal(plaintext: String): String { + val ciphertext = aead.encrypt(plaintext.toByteArray(Charsets.UTF_8), ASSOCIATED_DATA) + return Base64.encodeToString(ciphertext, Base64.NO_WRAP) + } + + /** + * Decrypt what [seal] produced. AEAD, so a tampered, truncated or foreign blob throws instead of + * yielding attacker-shaped bytes. + * + * @throws GeneralSecurityException if [sealed] is not an intact blob this device's key can open. + */ + public fun open(sealed: String): String { + val ciphertext = try { + Base64.decode(sealed, Base64.NO_WRAP) + } catch (e: IllegalArgumentException) { + // Deliberately does not echo `sealed` — it is the credential's ciphertext. + throw GeneralSecurityException("Sealed auth-cookie blob was not valid base64", e) + } + return String(aead.decrypt(ciphertext, ASSOCIATED_DATA), Charsets.UTF_8) + } + + /** Redacted by construction: this object's whole purpose is a credential. */ + override fun toString(): String = "TinkAuthCookieCipher(keyset=$keysetName)" + + public companion object { + private const val DEFAULT_KEYSET_NAME = "webterm_auth_cookie_keyset" + private const val DEFAULT_PREF_FILE = "webterm_auth_cookie_prefs" + private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_auth_cookie_master_key" + + /** + * AEAD associated data — binds the ciphertext to the auth-cookie context, so a blob lifted + * from (or into) another Tink slot in this app fails to decrypt. Not secret. + */ + private val ASSOCIATED_DATA: ByteArray = "webterm.host-registry.auth-cookie".toByteArray() + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt index 5e8e333..ff15458 100644 --- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt @@ -4,10 +4,6 @@ import android.content.Context import android.content.SharedPreferences import android.util.Base64 import com.google.crypto.tink.Aead -import com.google.crypto.tink.KeyTemplates -import com.google.crypto.tink.RegistryConfiguration -import com.google.crypto.tink.aead.AeadConfig -import com.google.crypto.tink.integration.android.AndroidKeysetManager /** * At-rest storage for the encrypted device-identity "live pointer" ([StoredIdentityMetadata]). @@ -37,9 +33,10 @@ public interface CertStore { * - NOT stored: the private key (non-exportable in AndroidKeyStore — the one key home), the raw * `.p12` bytes, and the passphrase. * - * The keyset + ciphertext live in an app-private `SharedPreferences` file (uninstall-wiped, excluded - * from cloud backup by the app manifest); the Tink keyset itself is encrypted by the AndroidKeystore - * master key so the on-disk keyset is useless off this device. + * The keyset + ciphertext live in an app-private `SharedPreferences` file (uninstall-wiped, and kept out + * of Android Auto Backup by `android:allowBackup="false"` in the app manifest — verify that attribute + * before trusting this sentence); the Tink keyset itself is encrypted by the AndroidKeystore master key, + * so even a copied-out file is useless off this device. */ public class TinkCertStore( context: Context, @@ -105,16 +102,8 @@ public class TinkCertStore( } } - private fun buildAead(): Aead { - AeadConfig.register() - val keysetHandle = AndroidKeysetManager.Builder() - .withSharedPref(appContext, keysetName, prefFileName) - .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE)) - .withMasterKeyUri(masterKeyUri) - .build() - .keysetHandle - return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) - } + private fun buildAead(): Aead = + buildAndroidKeystoreAead(appContext, keysetName, prefFileName, masterKeyUri) private fun prefs(): SharedPreferences = appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE) @@ -123,7 +112,6 @@ public class TinkCertStore( private const val DEFAULT_KEYSET_NAME = "webterm_cert_keyset" private const val DEFAULT_PREF_FILE = "webterm_client_tls_prefs" private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_cert_master_key" - private const val AEAD_KEY_TEMPLATE = "AES256_GCM" private const val BLOB_KEY = "cert_chain_blob" /** diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt index f999228..e3134ed 100644 --- a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt @@ -5,15 +5,13 @@ import okhttp3.OkHttpClient 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.api.enroll.DeviceEnrollmentClient import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError import wang.yaojia.webterm.testsupport.FakeHttpTransport import wang.yaojia.webterm.wire.HttpMethod -import java.security.KeyPairGenerator -import java.security.interfaces.ECPublicKey -import java.security.spec.ECGenParameterSpec /** * B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs @@ -30,53 +28,32 @@ class DeviceEnrollerTest { const val BASE = "https://cp.terminal.yaojia.wang" const val ALIAS = "test-device-key" - // Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory / - // CertificateSummaryReader parse them exactly as they parse a server-issued leaf. - const val LEAF_CN = "t1-device" - const val CA_CN = "webterm-device-ca" - const val LEAF_B64 = - "MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" + - "ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" + - "WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" + - "cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" + - "HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" + - "ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" + - "cdoleWDlqcMKU" - const val CA_B64 = - "MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" + - "dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" + - "YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" + - "e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" + - "4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" + - "Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" + - "YloHuEAg+kngzA33m52aWtublai4L+eybg==" + // Real self-signed P-256 X.509 certs + a software key double, shared with the rotation tests. + const val LEAF_CN = RotationTestFixtures.LEAF_CN + const val CA_CN = RotationTestFixtures.CA_CN - fun loginBody(): ByteArray = - """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray() + fun loginBody(): ByteArray = RotationTestFixtures.loginBody() - fun enrollBody(deviceId: String = "dev-1"): ByteArray = - """ - {"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"], - "notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z", - "renewAfter":"2026-09-05T00:00:00.000Z"} - """.trimIndent().toByteArray() + fun enrollBody(deviceId: String = "dev-1"): ByteArray = RotationTestFixtures.enrollBody(deviceId) - fun softwareKey(alias: String): HardwareBackedKey { - val kpg = KeyPairGenerator.getInstance("EC") - kpg.initialize(ECGenParameterSpec("secp256r1")) - val kp = kpg.generateKeyPair() - return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey) - } + fun softwareKey(alias: String): HardwareBackedKey = RotationTestFixtures.softwareKey(alias) } private val events = mutableListOf() private val transport = FakeHttpTransport() + + /** + * The recovery transport is a SEPARATE double on purpose: `/device/:id/recover` must ride a PLAIN + * (non-mTLS) client, because no TLS terminator forwards an expired client certificate. Using two + * transports is what lets these tests prove the routing, not just the request shape. + */ + private val recoveryTransport = FakeHttpTransport() private val certStore = RecordingCertStore(events) private val recordStore = RecordingRecordStore(events) private val keyProvider = RecordingKeyProvider(events) private val refresher = RecordingRefresher(events) - private fun enroller(): DeviceEnroller = + private fun enroller(withRecovery: Boolean = true): DeviceEnroller = DeviceEnroller( client = DeviceEnrollmentClient(BASE, transport), certStore = certStore, @@ -85,6 +62,7 @@ class DeviceEnrollerTest { keyAlias = ALIAS, keyProvider = keyProvider, cacheRefresher = refresher, + recoveryClient = if (withRecovery) DeviceEnrollmentClient(BASE, recoveryTransport) else null, ) // ── enroll: commit sequencing (the security-critical invariant) ──────────────────────────── @@ -221,6 +199,166 @@ class DeviceEnrollerTest { assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit") } + @Test + fun enrollPersistsTheControlPlaneUrlSoRotationKnowsWhereToRenew() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody()) + + enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel") + + // Silent rotation happens with no UI, so the control plane the device enrolled against must be + // persisted — a rotation that fell back to a compiled-in default would renew somewhere else. + assertEquals(BASE, recordStore.saved!!.controlPlaneUrl) + } + + @Test + fun renewDecodesTheRealReissueResponseThatCarriesNoDeviceId() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + // The real /device/:id/renew 201 body — {cert, caChain, notAfter} with NO deviceId. + transport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = RotationTestFixtures.reissueBody(), + ) + + val summary = enroller().renew() + + assertEquals(LEAF_CN, summary.subjectCommonName) + assertEquals("dev-1", recordStore.saved!!.deviceId, "the device id survives the re-issue") + assertEquals(BASE, recordStore.saved!!.controlPlaneUrl) + } + + // ── recover: the EXPIRED-leaf escape hatch (plain HTTPS, cert in the body) ────────────────── + + @Test + fun recoverPostsTheStoredLeafAndAFreshCsrOnThePlainTransportNotTheMtlsOne() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(), + ) + + val summary = enroller().recover() + + // The request went out on the PLAIN transport. If it had gone on the mTLS one it would have + // presented the expired leaf and nginx would have answered a bare 400 — the production deadlock. + assertTrue(transport.recordedRequests.isEmpty(), "recovery must NEVER ride the mTLS transport") + val request = recoveryTransport.recordedRequests.single() + assertEquals("$BASE/device/dev-1/recover", request.url) + assertNull(request.headers["Authorization"], "recovery carries no bearer") + val body = request.body!!.decodeToString() + assertTrue(body.contains("\"cert\":"), "the lapsed leaf travels in the BODY") + assertTrue(body.contains(RotationTestFixtures.LEAF_B64.take(32)), "the body carries the stored leaf") + assertTrue(body.contains("\"csr\":"), "possession is proven by a fresh CSR over the same key") + assertEquals(LEAF_CN, summary.subjectCommonName) + } + + @Test + fun recoverCommitsWithTheSameSequencingAsEnrollAndRenew() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(), + ) + + enroller().recover() + + assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"), "record before the pointer flip") + assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh after the commit") + } + + @Test + fun recoverReusesTheSameHardwareKeyAndNeverGeneratesANewOne() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(), + ) + + enroller().recover() + + // The server enforces `CSR key == registered key`; generating a key here would guarantee a 403. + assertTrue(keyProvider.generatedAliases.isEmpty(), "recovery re-CSRs from the EXISTING key") + assertTrue(keyProvider.deletedAliases.isEmpty(), "a successful recovery deletes nothing") + } + + @Test + fun recoverThrowsWhenNoPlainRecoveryClientIsConfigured() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + + val error = runCatching { enroller(withRecovery = false).recover() }.exceptionOrNull() + + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(recoveryTransport.recordedRequests.isEmpty()) + assertTrue(transport.recordedRequests.isEmpty(), "and it must not silently fall back to mTLS") + } + + @Test + fun recoverThrowsWhenThereIsNoStoredLeafToPresent() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + // No cert in the store → nothing to put in the body → a fresh enroll is the only way out. + val error = runCatching { enroller().recover() }.exceptionOrNull() + + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(recoveryTransport.recordedRequests.isEmpty(), "no pointless round-trip") + } + + @Test + fun recoverThrowsWhenNothingIsEnrolled() = runTest { + val error = runCatching { enroller().recover() }.exceptionOrNull() + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(recoveryTransport.recordedRequests.isEmpty()) + } + + @Test + fun aFailedRecoveryLeavesThePriorIdentityUntouched() = runTest { + val prior = storedIdentity() + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(prior) + // Beyond the 30-day grace the control plane answers a uniform 401. + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 401, body = """{"error":"rejected"}""".toByteArray(), + ) + + val error = runCatching { enroller().recover() }.exceptionOrNull() + + assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error) + assertSame(prior, certStore.load(), "a failed recovery must not touch the live cert pointer") + assertFalse(events.contains("cert.save"), "no pointer flip on failure") + assertFalse(events.contains("cache.refresh"), "and nothing is published to the in-memory cache") + assertTrue(keyProvider.deletedAliases.isEmpty(), "the key that still works is NEVER deleted") + } + + @Test + fun theRecoveryFailureNeverCarriesTheCertificateBytes() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 403, body = """{"error":"rejected"}""".toByteArray(), + ) + + val error = runCatching { enroller().recover() }.exceptionOrNull()!! + + val rendered = "${error.message} $error" + assertFalse(rendered.contains(RotationTestFixtures.LEAF_B64.take(32)), "no cert bytes in the error") + assertFalse(rendered.contains("MII"), "no DER/PEM material in a loggable rendering") + } + + private fun storedIdentity(): StoredIdentityMetadata = + StoredIdentityMetadata( + alias = ALIAS, + keyAlgorithm = "EC", + keyStoreAlias = ALIAS, + certificateChain = listOf(RotationTestFixtures.leafCertificate(), RotationTestFixtures.caCertificate()), + ) + // ── remove: full teardown ────────────────────────────────────────────────────────────────── @Test @@ -240,17 +378,25 @@ class DeviceEnrollerTest { private class RecordingCertStore(private val events: MutableList) : CertStore { var saved: StoredIdentityMetadata? = null var cleared = false + private var current: StoredIdentityMetadata? = null + + /** Pre-existing on-disk identity (the lapsed leaf the recovery path must present). */ + fun seed(metadata: StoredIdentityMetadata) { + current = metadata + } override fun save(metadata: StoredIdentityMetadata) { saved = metadata + current = metadata events += "cert.save" } - override fun load(): StoredIdentityMetadata? = saved + override fun load(): StoredIdentityMetadata? = current override fun clear() { cleared = true saved = null + current = null events += "cert.clear" } } diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStoreTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStoreTest.kt new file mode 100644 index 0000000..777cf0a --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStoreTest.kt @@ -0,0 +1,166 @@ +package wang.yaojia.webterm.tlsandroid + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.IOException +import java.time.Duration +import java.time.Instant + +/** + * [DeviceRotationStore] is the read side of silent rotation: it composes the two persisted stores into + * the timing snapshot the pure `RotationPolicy` consumes. The load-bearing behaviour is that the + * timing comes from the LIVE LEAF CERTIFICATE, not from the enrollment record — a record written by a + * renew/recover commit has no `renewAfter` at all (the server does not return one), so a store that + * trusted the record would rotate once and then report "not due" until the cert died. + */ +class DeviceRotationStoreTest { + private companion object { + const val ALIAS = "test-device-key" + const val CP_URL = "https://cp.terminal.yaojia.wang" + } + + private val certStore = FakeCertStore() + private val recordStore = FakeRecordStore() + private val store = DeviceRotationStore(certStore, recordStore) + + /** JUnit5's `assertNotNull` returns Unit, so assert-and-unwrap in one place. */ + private fun readSnapshot(): DeviceRotationSnapshot { + val snapshot = store.read() + assertNotNull(snapshot, "expected an enrolled rotation snapshot") + return snapshot!! + } + + private fun seedEnrolled( + controlPlaneUrl: String = CP_URL, + renewAfterEpochSeconds: Long = 0L, + ) { + recordStore.current = EnrollmentRecord( + deviceId = "dev-1", + deviceName = "Alice Pixel", + keyStoreAlias = ALIAS, + renewAfterEpochSeconds = renewAfterEpochSeconds, + controlPlaneUrl = controlPlaneUrl, + ) + certStore.current = StoredIdentityMetadata( + alias = ALIAS, + keyAlgorithm = "EC", + keyStoreAlias = ALIAS, + certificateChain = listOf(RotationTestFixtures.leafCertificate(), RotationTestFixtures.caCertificate()), + ) + } + + @Test + fun readReturnsNullWhenNothingIsEnrolled() { + assertNull(store.read(), "a fresh install has nothing to rotate") + } + + @Test + fun readReturnsNullWhenTheCertLivePointerIsAbsent() { + recordStore.current = EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, CP_URL) + // A record with no live cert cannot be renewed OR recovered (recovery needs the lapsed leaf to + // put in the body), so this is "nothing to rotate", not a failure. + assertNull(store.read()) + } + + @Test + fun readTakesTheExpiryFromTheLeafCertificate() { + seedEnrolled() + val snapshot = readSnapshot() + assertEquals("dev-1", snapshot.renewalState.deviceId) + assertEquals( + Instant.parse(RotationTestFixtures.LEAF_NOT_AFTER), + snapshot.renewalState.notAfter, + "notAfter is the leaf's own hard expiry — the value the TLS stack actually enforces", + ) + } + + @Test + fun readDerivesRenewAfterFromTheLeafAndIgnoresTheStaleRecordValue() { + // A record committed by a renew carries renewAfterEpochSeconds = 0 (unknown) because the + // renew 201 has no renewAfter; a record committed by an enroll carries a value that is stale + // the moment the leaf is replaced. Either way the LEAF is the source of truth. + seedEnrolled(renewAfterEpochSeconds = Instant.parse("1999-01-01T00:00:00Z").epochSecond) + val notBefore = Instant.parse(RotationTestFixtures.LEAF_NOT_BEFORE) + val notAfter = Instant.parse(RotationTestFixtures.LEAF_NOT_AFTER) + // The control plane's own formula: notBefore + 2/3 · lifetime. + val expected = notBefore.plus(Duration.between(notBefore, notAfter).multipliedBy(2).dividedBy(3)) + + val snapshot = readSnapshot() + + assertEquals(expected, snapshot.renewalState.renewAfter) + assertTrue(snapshot.renewalState.renewAfter!!.isAfter(notBefore)) + assertTrue(snapshot.renewalState.renewAfter!!.isBefore(notAfter)) + } + + @Test + fun readCarriesTheControlPlaneUrlSoRotationTargetsTheRightHost() { + seedEnrolled(controlPlaneUrl = "https://cp.example.test") + assertEquals("https://cp.example.test", readSnapshot().controlPlaneUrl) + } + + @Test + fun readReportsABlankControlPlaneUrlRatherThanGuessingOne() { + // A record written before the URL was persisted decodes to "". Renewing against a DEFAULT host + // would silently talk to the wrong control plane, so the blank is surfaced verbatim and the + // caller must refuse to rotate. + seedEnrolled(controlPlaneUrl = "") + assertEquals("", readSnapshot().controlPlaneUrl) + } + + @Test + fun readPropagatesAStoreFaultInsteadOfReportingNotEnrolled() { + seedEnrolled() + certStore.fault = IOException("tink blob unreadable") + // Degrading a decrypt fault to "nothing enrolled" would silently disable rotation forever; the + // caller surfaces it as a failure instead. + assertTrue(runCatching { store.read() }.exceptionOrNull() is IOException) + } + + @Test + fun theSnapshotCarriesNoCredentialInItsToString() { + seedEnrolled() + val rendered = readSnapshot().toString() + assertTrue(rendered.contains("dev-1")) + assertTrue(rendered.contains("cp.terminal.yaojia.wang")) + assertFalse(rendered.contains("MIIB"), "no certificate DER may reach a loggable rendering") + assertFalse(rendered.contains(RotationTestFixtures.LEAF_B64), "no leaf bytes in the snapshot") + } + + // ── doubles ──────────────────────────────────────────────────────────────────────────────── + + private class FakeCertStore : CertStore { + var current: StoredIdentityMetadata? = null + var fault: Exception? = null + + override fun save(metadata: StoredIdentityMetadata) { + current = metadata + } + + override fun load(): StoredIdentityMetadata? { + fault?.let { throw it } + return current + } + + override fun clear() { + current = null + } + } + + private class FakeRecordStore : EnrollmentRecordStore { + var current: EnrollmentRecord? = null + + override fun save(record: EnrollmentRecord) { + current = record + } + + override fun load(): EnrollmentRecord? = current + + override fun clear() { + current = null + } + } +} diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordCodecTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordCodecTest.kt new file mode 100644 index 0000000..12903d6 --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordCodecTest.kt @@ -0,0 +1,53 @@ +package wang.yaojia.webterm.tlsandroid + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream + +/** + * [EnrollmentRecordCodec] round-trip, plus the one compatibility rule that matters: a blob written + * BEFORE `controlPlaneUrl` existed must still decode (to a blank URL) rather than being reported as + * corrupt — a corrupt-blob verdict on an existing enrollment would strand a working device. + */ +class EnrollmentRecordCodecTest { + + @Test + fun roundTripsEveryField() { + val record = EnrollmentRecord( + deviceId = "dev-1", + deviceName = "Alice Pixel", + keyStoreAlias = "webterm-device-key", + renewAfterEpochSeconds = 1_790_000_000L, + controlPlaneUrl = "https://cp.terminal.yaojia.wang", + ) + assertEquals(record, EnrollmentRecordCodec.decode(EnrollmentRecordCodec.encode(record))) + } + + @Test + fun aBlobWrittenBeforeTheControlPlaneUrlFieldDecodesToABlankUrl() { + // Exactly the four-field layout the previous codec version emitted. + val legacy = ByteArrayOutputStream().also { out -> + DataOutputStream(out).use { data -> + data.writeUTF("dev-1") + data.writeUTF("Alice Pixel") + data.writeUTF("webterm-device-key") + data.writeLong(0L) + } + }.toByteArray() + + val decoded = EnrollmentRecordCodec.decode(legacy) + + assertEquals("dev-1", decoded.deviceId) + assertEquals("", decoded.controlPlaneUrl, "an absent trailing field is unknown, not corrupt") + } + + @Test + fun aTruncatedBlobIsStillReportedAsCorrupt() { + // Tolerating the ABSENT trailing field must not turn into tolerating a mangled record. + assertThrows(CorruptStoredIdentityException::class.java) { + EnrollmentRecordCodec.decode(byteArrayOf(0x00, 0x05, 0x64)) + } + } +} diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/RotationTestFixtures.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/RotationTestFixtures.kt new file mode 100644 index 0000000..7d7d36d --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/RotationTestFixtures.kt @@ -0,0 +1,78 @@ +package wang.yaojia.webterm.tlsandroid + +import java.security.KeyPairGenerator +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.util.Base64 + +/** + * Shared JVM test fixtures for the enroll / rotate / recover orchestration: REAL self-signed P-256 + * X.509 certificates (base64 DER) so `CertificateFactory`, `CertificateSummaryReader` and the + * rotation-timing reader parse them exactly as they parse a server-issued leaf — and a software P-256 + * key double standing in for the non-exportable AndroidKeyStore key (which needs a device). + */ +internal object RotationTestFixtures { + const val LEAF_CN: String = "t1-device" + const val CA_CN: String = "webterm-device-ca" + + /** Validity window baked into [LEAF_B64] (read back from the DER, asserted in the store tests). */ + const val LEAF_NOT_BEFORE: String = "2026-07-18T11:21:11Z" + const val LEAF_NOT_AFTER: String = "2126-06-24T11:21:11Z" + + const val LEAF_B64: String = + "MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" + + "ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" + + "WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" + + "cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" + + "HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" + + "ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" + + "cdoleWDlqcMKU" + + const val CA_B64: String = + "MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" + + "dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" + + "YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" + + "e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" + + "4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" + + "Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" + + "YloHuEAg+kngzA33m52aWtublai4L+eybg==" + + fun leafDer(): ByteArray = Base64.getDecoder().decode(LEAF_B64) + + fun leafCertificate(): X509Certificate = parse(leafDer()) + + fun caCertificate(): X509Certificate = parse(Base64.getDecoder().decode(CA_B64)) + + /** The enroll 201 body — the ONLY route that returns deviceId/notBefore/renewAfter. */ + fun enrollBody(deviceId: String = "dev-1"): ByteArray = + """ + {"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"], + "notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z", + "renewAfter":"2026-09-05T00:00:00.000Z"} + """.trimIndent().toByteArray() + + /** + * The REAL `/device/:id/renew` and `/device/:id/recover` 201 body: `{cert, caChain, notAfter}` — + * no deviceId, no notBefore, no renewAfter (control-plane/src/api/renew.ts). + */ + fun reissueBody(): ByteArray = + """ + {"cert":"$LEAF_B64","caChain":["$CA_B64"],"notAfter":"2126-06-24T11:21:11.000Z"} + """.trimIndent().toByteArray() + + fun loginBody(): ByteArray = + """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray() + + /** A software P-256 key standing in for the non-exportable AndroidKeyStore key (§7: no emulator). */ + fun softwareKey(alias: String): HardwareBackedKey { + val generator = KeyPairGenerator.getInstance("EC") + generator.initialize(ECGenParameterSpec("secp256r1")) + val pair = generator.generateKeyPair() + return HardwareBackedKey(alias, pair.private, pair.public as ECPublicKey) + } + + private fun parse(der: ByteArray): X509Certificate = + CertificateFactory.getInstance("X.509").generateCertificate(der.inputStream()) as X509Certificate +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a179dfa..4d73b9d 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,5 +1,6 @@ ## Version catalog — single source of truth for plugin & dependency versions. -## Pure JVM modules only (this env has NO Android SDK). See android/README.md. +## Covers BOTH the pure Kotlin/JVM modules and the Android-framework modules (the +## SDK is installed on this machine — see android/README.md "Android SDK setup"). ## Kotlin pinned to 2.3.21 to match the Kotlin embedded in Gradle 9.6.1 (best ## compatibility with the Kotlin Gradle Plugin on Gradle 9.6). @@ -52,10 +53,18 @@ datastore = "1.1.1" # listenablefuture conflict dep is unnecessary here). termux = "v0.118.0" # androidx-test — lets androidTest (instrumented) sources COMPILE here; they only -# RUN on a device (no emulator installed → deferred to device QA, plan §7). +# RUN on a device/emulator (plan §7 — the A34 E2E suite needs a real `npm start` host). androidxTestExtJunit = "1.2.1" androidxTestCore = "1.6.1" androidxTestRunner = "1.6.2" +androidxTestRules = "1.6.1" +# Espresso + UiAutomator drive the A35 happy path (in-process Compose assertions and +# out-of-process device interaction respectively). Pinned to the androidx-test 1.6 line. +espresso = "3.6.1" +uiautomator = "2.3.0" +# Macrobenchmark (A35) — startup/frame timing measured from a SEPARATE `com.android.test` +# module against a non-debuggable, minified `benchmark` variant of :app. +benchmark = "1.3.4" # QR pairing (A19): CameraX preview/analysis + on-device ML Kit barcode scanning. camerax = "1.4.1" mlkitBarcode = "17.3.0" @@ -127,6 +136,20 @@ termux-terminal-emulator = { module = "com.github.termux.termux-app:terminal-emu androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExtJunit" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } +androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +# Compose UI test — BOM-managed (do NOT pin). `ui-test-manifest` is a debugImplementation: +# it supplies the empty activity `createComposeRule()` launches into. +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +# Hilt instrumented testing — supplies `HiltAndroidRule` + the generated +# `dagger.hilt.android.testing.HiltTestApplication` that :app's custom runner installs. +# REQUIRES kspAndroidTest(hilt-compiler); without the processor HiltTestApplication is +# never generated and the runner will not compile. +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +# Macrobenchmark (A35) — used by the :macrobenchmark `com.android.test` module only. +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" } # QR pairing camera (A19) androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } @@ -148,6 +171,17 @@ unit-test = ["junit-jupiter", "kotlinx-coroutines-test", "turbine", "mockk"] # CameraX (A19 QR pairing) — preview + analysis + PreviewView. camerax = ["androidx-camera-core", "androidx-camera-camera2", "androidx-camera-lifecycle", "androidx-camera-view"] +# Instrumented (androidTest) foundation — wired into :app's androidTestImplementation. +# JUnit4 arrives transitively via androidx.test.ext:junit (AndroidJUnitRunner is JUnit4-only; +# the JVM unit tests stay on the JUnit5 platform, which instrumented tasks never touch). +android-instrumented-test = [ + "androidx-test-ext-junit", + "androidx-test-core", + "androidx-test-runner", + "androidx-test-rules", + "androidx-test-espresso-core", +] + # Compose UI + Material 3 (+ Adaptive) — wired into :app implementation. All # BOM-managed; add ui-tooling separately as a debugImplementation. compose = [ @@ -167,6 +201,12 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" } +# NOTE: there is deliberately NO `android-test` alias for :macrobenchmark's +# `com.android.test` plugin. The two aliases above put AGP on the build classpath with +# an "unknown version", so a THIRD versioned alias for the same artifact is rejected +# ("plugin is already on the classpath with an unknown version"). :macrobenchmark +# therefore applies `id("com.android.test")` bare and inherits the `agp` version above — +# exactly the pattern :terminal-view uses for `id("com.android.library")`. kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } # NOTE: AGP 9+ has BUILT-IN Kotlin support — Android modules apply only the # android plugin, NOT a separate org.jetbrains.kotlin.android (it errors out). diff --git a/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreTest.kt b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreTest.kt new file mode 100644 index 0000000..5cc9063 --- /dev/null +++ b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreTest.kt @@ -0,0 +1,217 @@ +package wang.yaojia.webterm.hostregistry + +import android.content.Context +import android.util.Base64 +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.UUID + +/** + * Instrumented (device) contract tests for [DataStoreAuthCookieStore] — the real + * Preferences-DataStore-backed store needs a Context, so these run on a device (no emulator in the + * build env → compiled here, executed in device QA, plan §7). They mirror the JVM + * [InMemoryAuthCookieStoreTest] contract and add the storage-only properties: the blob survives a + * genuine cold start, a record that expired while at rest is never handed back, and — the security + * one — the credential never appears in the DataStore FILE on disk, sealed or fail-closed. + * + * The at-rest encryption is driven through a reversible test cipher: this module has no Tink + * dependency by design (see [AuthCookieCipher]). The real `AndroidKeyStore`-backed cipher is tested + * instrumented in `:client-tls-android` (`TinkAuthCookieCipherTest`). + * + * ⚠ Every DataStore is created inside [onFile], which CANCELS the store's scope on the way out. + * `datastore-core` throws `IllegalStateException("There are multiple DataStores active for the same + * file")` if a second instance touches a file the first still holds, so a cold-start test MUST release + * the first one before opening the second. + */ +@RunWith(AndroidJUnit4::class) +class DataStoreAuthCookieStoreTest { + + private val hostA = "http://10.0.0.1:3000" + private val hostB = "https://tunnel.example:443" + + private fun context(): Context = ApplicationProvider.getApplicationContext() + + private fun fileFor(name: String): File = context().preferencesDataStoreFile(name) + + /** + * Opens a DataStore over [name], runs [block] against a store wired to [cipher] and [now], then + * cancels the DataStore's scope so the file is released for the next cold start. + */ + private fun onFile( + name: String, + cipher: AuthCookieCipher = ReversibleTestCipher(), + now: () -> Long = { NOW }, + block: suspend (AuthCookieStore) -> T, + ): T = runBlocking { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val dataStore: DataStore = PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { fileFor(name) }, + ) + try { + block( + DataStoreAuthCookieStore( + dataStore = dataStore, + cipher = cipher, + onCipherFailure = {}, + now = now, + ), + ) + } finally { + scope.cancel() + scope.coroutineContext[Job]!!.join() + } + } + + private fun record(hostKey: String, name: String = "webterm_auth", expiresAt: Long = FAR_FUTURE) = + AuthCookieRecord( + hostKey = hostKey, + name = name, + value = SECRET, + domain = "10.0.0.1", + path = "/", + expiresAtEpochMillis = expiresAt, + httpOnly = true, + ) + + @Test + fun upsert_persists_and_loadForHost_reads_back() { + val a = record(hostA) + + onFile(newFileName()) { store -> + assertEquals(listOf(a), store.upsert(a)) + assertEquals(listOf(a), store.loadForHost(hostA)) + assertEquals(emptyList(), store.loadForHost(hostB)) + } + } + + @Test + fun the_blob_survives_a_recreated_store() { + // The cold-start path: a NEW store over the SAME (released) file must see the cookie. + val file = newFileName() + onFile(file) { it.upsert(record(hostA)) } + + assertEquals(1, onFile(file) { it.loadForHost(hostA) }.size) + } + + @Test + fun the_credential_never_appears_in_the_datastore_file() { + // Arrange + Act: write the cookie through the real DataStore, then read the FILE back. + val file = newFileName() + onFile(file) { it.upsert(record(hostA)) } + + // Assert: the bytes on disk hold no recoverable form of the credential. This is the property + // that makes a rooted device, an `adb` pull, a cloud backup or a D2D transfer harmless. + val bytes = fileFor(file).readBytes() + assertTrue("the DataStore file was never written", bytes.isNotEmpty()) + val onDisk = String(bytes, Charsets.ISO_8859_1) + assertFalse("the raw credential is on disk", onDisk.contains(SECRET)) + assertFalse( + "the credential is on disk merely Base64'd", + onDisk.contains(Base64.encodeToString(SECRET.toByteArray(), Base64.NO_WRAP)), + ) + } + + @Test + fun remove_unknown_is_noop_and_removeHost_clears_one_host_only() { + onFile(newFileName()) { store -> + store.upsert(record(hostA)) + store.upsert(record(hostB)) + + assertEquals(2, store.remove(record("http://nope:1").id).size) + assertEquals(listOf(hostB), store.removeHost(hostA).map { it.hostKey }) + } + } + + @Test + fun replaceHost_swaps_that_hosts_cookies_wholesale() { + onFile(newFileName()) { store -> + store.upsert(record(hostA, name = "stale")) + store.upsert(record(hostB)) + + val updated = store.replaceHost(hostA, listOf(record(hostA, name = "fresh"))) + + assertEquals(listOf("fresh"), updated.filter { it.hostKey == hostA }.map { it.name }) + assertEquals(1, updated.count { it.hostKey == hostB }) + } + } + + @Test + fun a_record_that_expired_at_rest_is_never_returned() { + // Arrange: written while live, read back after the clock moved past its expiry. + val file = newFileName() + val expiresAt = 1_800_000_000_000L + onFile(file, now = { expiresAt - 1 }) { it.upsert(record(hostA, expiresAt = expiresAt)) } + + // Act + val afterExpiry = onFile(file, now = { expiresAt + 1 }) { it.loadAll() } + + // Assert + assertTrue("a stale shell credential must never be resurrected", afterExpiry.isEmpty()) + } + + @Test + fun nothing_is_persisted_when_the_cipher_is_unavailable() { + // Arrange + Act: the fail-closed path — a device whose keystore cannot seal. + val file = newFileName() + val updated = onFile(file, cipher = ReversibleTestCipher(failSeal = true)) { + it.upsert(record(hostA)) + } + + // Assert: usable in memory, absent from disk — never a plaintext fallback. + assertEquals(1, updated.size) + val onDisk = fileFor(file) + assertFalse( + "the credential was written despite the cipher failing", + onDisk.exists() && String(onDisk.readBytes(), Charsets.ISO_8859_1).contains(SECRET), + ) + assertTrue(onFile(file) { it.loadAll() }.isEmpty()) + } + + /** + * Reversible stand-in for the Tink AEAD (XOR + Base64) — enough to exercise the store's + * seal/open/fail-closed paths without a Tink dependency, and confined to the test source set so it + * can never be wired into the app. The key is fixed so a cold-start store opens what the previous + * one sealed. + */ + private class ReversibleTestCipher( + private val failSeal: Boolean = false, + private val key: Byte = 0x2C, + ) : AuthCookieCipher { + override fun seal(plaintext: String): String { + if (failSeal) throw IllegalStateException("test cipher: keystore unavailable") + return Base64.encodeToString(plaintext.toByteArray().xored(), Base64.NO_WRAP) + } + + override fun open(sealed: String): String = + String(Base64.decode(sealed, Base64.NO_WRAP).xored()) + + private fun ByteArray.xored(): ByteArray = + ByteArray(size) { (this[it].toInt() xor key.toInt()).toByte() } + } + + private companion object { + /** Distinctive enough that finding it on disk is unambiguous. */ + const val SECRET = "SUPER_SECRET_SHELL_TOKEN_9f3a7c11" + const val NOW = 1_700_000_000_000L + const val FAR_FUTURE = 4_000_000_000_000L + + fun newFileName(): String = "cookies-${UUID.randomUUID()}" + } +} diff --git a/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStoreTest.kt b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStoreTest.kt new file mode 100644 index 0000000..f6822a3 --- /dev/null +++ b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStoreTest.kt @@ -0,0 +1,188 @@ +package wang.yaojia.webterm.hostregistry + +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.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID + +/** + * Instrumented (device) contract tests for [DataStoreSessionWatermarkStore] — the storage half + * of the module, which needs a real Context/file (plan §7). + * + * The load-bearing one is [survives_a_new_store_instance_over_the_same_file]: that is the + * process-death simulation this whole class exists for. The rest mirror the JVM transform tests + * at the storage boundary (garbage at rest is dropped, the cap holds, an emptied map removes the + * key instead of leaving `{}` behind). + */ +@RunWith(AndroidJUnit4::class) +class DataStoreSessionWatermarkStoreTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + /** + * A fresh, uniquely-named Preferences file per test (no cross-test bleed). [scope] is + * injectable because DataStore throws if two instances are live on ONE file: releasing the + * file means cancelling the owning scope, which is how process death is simulated below. + */ + private fun newDataStore( + name: String = "watermarks-test-${UUID.randomUUID()}", + scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + ): DataStore { + val ctx = ApplicationProvider.getApplicationContext() + return PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { ctx.preferencesDataStoreFile(name) }, + ) + } + + @Test + fun an_empty_store_loads_an_empty_map() = runBlocking { + assertTrue(DataStoreSessionWatermarkStore(newDataStore()).loadAll().isEmpty()) + } + + @Test + fun replaceAll_then_loadAll_returns_the_same_watermarks() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + + store.replaceAll(mapOf(idA to 10L, idB to 20L)) + + assertEquals(mapOf(idA to 10L, idB to 20L), store.loadAll()) + } + + @Test + fun survives_a_new_store_instance_over_the_same_file() = runBlocking { + val fileName = "watermarks-persist-${UUID.randomUUID()}" + val firstScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // replaceAll suspends until edit() has committed to disk. + DataStoreSessionWatermarkStore(newDataStore(fileName, firstScope)) + .replaceAll(mapOf(idA to 1_700_000_000_000L)) + + // Cancelling the owning scope releases the file — DataStore refuses two live instances + // on one file, and letting go of it is exactly what process death does. + firstScope.coroutineContext.job.cancelAndJoin() + + // A brand-new store over the same file = what a cold start after process death sees. + val reopened = DataStoreSessionWatermarkStore(newDataStore(fileName)) + + assertEquals(mapOf(idA to 1_700_000_000_000L), reopened.loadAll()) + } + + @Test + fun replaceAll_is_a_wholesale_swap_not_a_merge() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + store.replaceAll(mapOf(idA to 10L)) + + store.replaceAll(mapOf(idB to 20L)) + + assertEquals(mapOf(idB to 20L), store.loadAll()) + } + + @Test + fun remove_drops_one_session_and_keeps_the_rest() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + store.replaceAll(mapOf(idA to 10L, idB to 20L)) + + val remaining = store.remove(idA) + + assertEquals(mapOf(idB to 20L), remaining) + assertEquals(remaining, store.loadAll()) + } + + @Test + fun removing_an_unknown_session_is_a_no_op() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + store.replaceAll(mapOf(idA to 10L)) + + assertEquals(mapOf(idA to 10L), store.remove(idB)) + } + + @Test + fun emptying_the_map_removes_the_stored_key_instead_of_writing_an_empty_blob() = runBlocking { + val dataStore = newDataStore() + val store = DataStoreSessionWatermarkStore(dataStore) + store.replaceAll(mapOf(idA to 10L)) + + store.remove(idA) + + assertNull(dataStore.data.first()[stringPreferencesKey(WATERMARKS_KEY_NAME)]) + } + + @Test + fun garbage_stored_at_rest_reads_back_as_no_watermarks() = runBlocking { + val dataStore = newDataStore() + dataStore.edit { it[stringPreferencesKey(WATERMARKS_KEY_NAME)] = "{not json" } + + assertTrue(DataStoreSessionWatermarkStore(dataStore).loadAll().isEmpty()) + } + + @Test + fun a_non_v4_id_stored_at_rest_is_dropped_on_read() = runBlocking { + val dataStore = newDataStore() + // A v1 UUID: parses as a UUID (the DataStoreLastSessionStore precedent bug) but is not + // a valid wire session id, so Validation.isValidSessionId must reject it. + val v1 = "3f2504e0-4f89-11d3-9a0c-0305e82c3301" + dataStore.edit { it[stringPreferencesKey(WATERMARKS_KEY_NAME)] = """{"$idA":10,"$v1":20}""" } + + assertEquals(mapOf(idA to 10L), DataStoreSessionWatermarkStore(dataStore).loadAll()) + } + + @Test + fun the_stored_map_is_capped_so_it_cannot_grow_without_bound() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + val oversized = (0 until MAX_WATERMARKS + 5).associate { sessionId(it) to (it + 1).toLong() } + + val stored = store.replaceAll(oversized) + + assertEquals(MAX_WATERMARKS, stored.size) + assertEquals(MAX_WATERMARKS, store.loadAll().size) + assertFalse(store.loadAll().containsKey(sessionId(0))) // oldest-seen dropped first + } + + @Test + fun the_watermark_key_is_disjoint_from_the_other_stores_over_one_file() = runBlocking { + // ONE Preferences DataStore is shared app-wide (:app StorageModule) — this is the + // regression test for "never collide with a sibling store's key". + val dataStore = newDataStore() + val hosts = DataStoreHostStore(dataStore) + val lastSession = DataStoreLastSessionStore(dataStore) + val watermarks = DataStoreSessionWatermarkStore(dataStore) + val host = Host.create(id = "host-1", name = "mac", baseUrl = "http://10.0.2.2:3000")!! + + hosts.upsert(host) + lastSession.setLastSessionId(idB, hostId = "host-1") + watermarks.replaceAll(mapOf(idA to 10L)) + + assertEquals(listOf(host), hosts.loadAll()) + assertEquals(idB, lastSession.lastSessionId("host-1")) + assertEquals(mapOf(idA to 10L), watermarks.loadAll()) + } + + /** A distinct valid v4 id per [index] (the last 4 hex digits carry the index). */ + private fun sessionId(index: Int): String = + "3f2504e0-4f89-41d3-9a0c-0305e82c%04x".format(index) + + private companion object { + /** Must match `DataStoreSessionWatermarkStore.WATERMARKS_KEY` (private there, asserted here). */ + const val WATERMARKS_KEY_NAME = "unreadWatermarks" + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCipher.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCipher.kt new file mode 100644 index 0000000..4fdfa43 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCipher.kt @@ -0,0 +1,47 @@ +package wang.yaojia.webterm.hostregistry + +/** + * At-rest encryption seam for the persisted `webterm_auth` cookie. + * + * WHY A SEAM AND NOT A CONCRETE CIPHER: the stored cookie is a **full-shell credential** and plan §8 + * already requires the far-less-powerful device-cert chain to be Tink-AEAD encrypted under an + * `AndroidKeyStore` master key. The same treatment is therefore mandatory here — but the Tink + + * AndroidKeyStore implementation lives in `:client-tls-android`, and `:host-registry` must NOT gain + * that dependency (`android/README.md`: *dependencies only flow down; nothing points upward* — the two + * are siblings under `:app`). So the operation contract is declared HERE and implemented THERE, with + * `:app` bridging the two — exactly the precedent `:transport-okhttp`'s `ClientIdentityProvider` set + * for the mTLS material. + * + * ── Contract for implementations (security-load-bearing) ────────────────────────────────────────── + * - [seal] must produce a value the plaintext **cannot** be recovered from without a key that never + * leaves the device (i.e. real authenticated encryption — an encoding such as Base64 is NOT a + * cipher and is a contract violation). + * - [open] must be **authenticated**: a tampered, truncated or foreign blob must throw, never return + * attacker-shaped plaintext. + * - Failure is signalled by throwing. Both directions may fail (keystore unavailable, key lost after + * a restore-to-new-device, tamper) and [DataStoreAuthCookieStore] fails CLOSED on either — a seal + * failure drops persistence rather than falling back to plaintext. + * - Neither the plaintext nor any part of it may appear in an exception message, a log line or + * `toString()`. The plaintext handed to [seal] contains the credential. + * + * There is deliberately **no** in-`main` reversible/no-op implementation (unlike [InMemoryHostStore] + * and [InMemoryAuthCookieStore], which are harmless): a pass-through "cipher" reachable from DI would + * silently reinstate plaintext persistence. Test doubles live in test source sets only. When no real + * cipher can be built, bind [InMemoryAuthCookieStore] instead — memory-only persistence is the + * fail-closed answer (the user re-authenticates after a restart). + */ +public interface AuthCookieCipher { + /** + * Encrypt [plaintext] into an opaque, storable string. + * + * @throws Exception if encryption is unavailable — the caller then persists NOTHING. + */ + public fun seal(plaintext: String): String + + /** + * Decrypt what [seal] produced. + * + * @throws Exception if [sealed] is not an intact blob this device's key can open. + */ + public fun open(sealed: String): String +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodec.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodec.kt new file mode 100644 index 0000000..d87f1ab --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodec.kt @@ -0,0 +1,86 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * At-rest DTO for an [AuthCookieRecord]. Separate from the domain type so the storage format can + * drift independently, and so nothing but this file ever serialises the credential. + * + * [toString] is redacted here too: a decode failure or a debug print of the intermediate list must + * not spill the cookie value. + */ +@Serializable +internal data class PersistedAuthCookie( + val hostKey: String, + val name: String, + val value: String, + val domain: String, + val path: String, + val expiresAtEpochMillis: Long, + val secure: Boolean = false, + val httpOnly: Boolean = false, + val hostOnly: Boolean = true, +) { + override fun toString(): String = "PersistedAuthCookie(hostKey=$hostKey, name=$name, value=)" +} + +/** + * Pure JSON codec for the persisted auth-cookie list (JVM-testable without a Context, mirrors + * [HostCodec]). Encode is total; decode is defensive — cookie records are UNTRUSTED at rest: + * - a corrupt/undecodable blob → empty list (start clean, never crash); + * - a record with a blank `hostKey`, `name` or `domain` → dropped (a record with no host scope + * could otherwise be replayed to the wrong host); + * - a record that expired while at rest → dropped, so a stale shell credential is never + * resurrected after a long background or a clock jump. + */ +internal object AuthCookieCodec { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + + fun encode(records: List): String = + json.encodeToString(records.map { it.toPersisted() }) + + fun decode(raw: String?, nowEpochMillis: Long): List { + if (raw.isNullOrBlank()) return emptyList() + val persisted = try { + json.decodeFromString>(raw) + } catch (_: Exception) { + return emptyList() // corrupted blob at rest → start clean, never crash + } + return persisted.mapNotNull { it.toRecordOrNull() }.droppingExpired(nowEpochMillis) + } + + private fun AuthCookieRecord.toPersisted(): PersistedAuthCookie = + PersistedAuthCookie( + hostKey = hostKey, + name = name, + value = value, + domain = domain, + path = path, + expiresAtEpochMillis = expiresAtEpochMillis, + secure = secure, + httpOnly = httpOnly, + hostOnly = hostOnly, + ) + + private fun PersistedAuthCookie.toRecordOrNull(): AuthCookieRecord? { + if (hostKey.isBlank() || name.isBlank() || domain.isBlank()) return null + return AuthCookieRecord( + hostKey = hostKey, + name = name, + value = value, + domain = domain, + path = path, + expiresAtEpochMillis = expiresAtEpochMillis, + secure = secure, + httpOnly = httpOnly, + hostOnly = hostOnly, + ) + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieRecord.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieRecord.kt new file mode 100644 index 0000000..9cb8836 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieRecord.kt @@ -0,0 +1,51 @@ +package wang.yaojia.webterm.hostregistry + +/** + * One persisted session cookie, scoped to exactly one paired host. + * + * Background: when the server runs with `WEBTERM_TOKEN` set (`src/http/auth.ts`), pairing yields a + * `webterm_auth` cookie and every later remote HTTP route — plus the WS upgrade — requires it. + * `:transport-okhttp`'s `AuthCookieJar` owns the live cookie semantics; this module only makes the + * cookie survive a process restart. + * + * - [hostKey] is the isolation key produced by `authCookieHostKey` (`scheme://host:port`). It is the + * reason a record can never be replayed to a different host: reads go through [forHost]. + * - [expiresAtEpochMillis] is ABSOLUTE wall-clock milliseconds, never a `Max-Age` duration — + * persisting a duration would restart the credential's lifetime on every load. + * - [value] is a **shell credential**. [toString] is redacted so it can never land in a log, a crash + * report or a debugger dump of a surrounding data class. + */ +public data class AuthCookieRecord( + val hostKey: String, + val name: String, + val value: String, + val domain: String, + val path: String, + val expiresAtEpochMillis: Long, + val secure: Boolean = false, + val httpOnly: Boolean = false, + val hostOnly: Boolean = true, +) { + /** Storage identity: our host scope plus RFC 6265's (domain, path, name) cookie key. */ + val id: AuthCookieId + get() = AuthCookieId(hostKey = hostKey, domain = domain, path = path, name = name) + + /** True iff the record is still live at [nowEpochMillis] (expiry is inclusive → already dead). */ + public fun isLiveAt(nowEpochMillis: Long): Boolean = expiresAtEpochMillis > nowEpochMillis + + override fun toString(): String = + "AuthCookieRecord(hostKey=$hostKey, name=$name, value=, domain=$domain, path=$path, " + + "expiresAtEpochMillis=$expiresAtEpochMillis, secure=$secure, httpOnly=$httpOnly, hostOnly=$hostOnly)" +} + +/** + * The composite identity of a stored cookie. A value class rather than a joined string on purpose: + * cookie names and paths may legally contain the separator characters a string key would need, and a + * collision there would let one record silently overwrite another. + */ +public data class AuthCookieId( + val hostKey: String, + val domain: String, + val path: String, + val name: String, +) diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStore.kt new file mode 100644 index 0000000..2dcad29 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStore.kt @@ -0,0 +1,74 @@ +package wang.yaojia.webterm.hostregistry + +/** + * Durable per-host storage for the `webterm_auth` session cookie (plan §3 :host-registry, server + * `src/http/auth.ts`). Implementations: [DataStoreAuthCookieStore] (real) and + * [InMemoryAuthCookieStore] (in-`main` double, exactly like [InMemoryHostStore]). + * + * Same immutable style as [HostStore]: every mutation RETURNS the new collection instead of mutating + * shared state, upsert preserves position, and an unknown id is an explicit no-op (never a throw). + * + * Host isolation is part of the contract, not a convention: [loadForHost] must never return a record + * filed under a different `hostKey`, and [replaceHost] must never file another host's record under + * the host being replaced. The stored value is a shell credential. + */ +public interface AuthCookieStore { + /** Every stored cookie across all hosts, in stored (insertion) order. */ + public suspend fun loadAll(): List + + /** Only [hostKey]'s cookies — the cold-start hydration read. Unknown host → empty list. */ + public suspend fun loadForHost(hostKey: String): List + + /** Insert, or replace the record with the same [AuthCookieRecord.id] in place. Returns the new list. */ + public suspend fun upsert(record: AuthCookieRecord): List + + /** Remove one record. Unknown [id] is a no-op returning the unchanged collection. */ + public suspend fun remove(id: AuthCookieId): List + + /** Forget everything stored for [hostKey] (unpair / sign-out). Other hosts are untouched. */ + public suspend fun removeHost(hostKey: String): List + + /** + * Swap [hostKey]'s cookies for [records] wholesale — the write the OkHttp cookie jar drives + * ("this is host X's cookie set now"). An empty [records] clears the host. Records whose + * `hostKey` differs are DROPPED rather than trusted. + */ + public suspend fun replaceHost(hostKey: String, records: List): List +} + +// ── Pure immutable transforms shared by every AuthCookieStore (DRY, mirrors HostStore.kt) ───────── +// Never mutate the receiver — always return a fresh list. + +/** Insert [record], or replace the entry with the same id IN PLACE (position preserved). */ +internal fun List.upserting(record: AuthCookieRecord): List = + if (any { it.id == record.id }) { + map { if (it.id == record.id) record else it } + } else { + this + record + } + +/** A new list without the record whose id equals [id]. Unknown id → unchanged contents (no-op). */ +internal fun List.removing(id: AuthCookieId): List = + filter { it.id != id } + +/** Only the records belonging to [hostKey] — THE cross-host leak guard on the read path. */ +internal fun List.forHost(hostKey: String): List = + filter { it.hostKey == hostKey } + +/** A new list with every record of [hostKey] removed; other hosts untouched. */ +internal fun List.removingHost(hostKey: String): List = + filter { it.hostKey != hostKey } + +/** + * A new list where [hostKey]'s records are exactly [records] (appended after the surviving records + * of other hosts). Records carrying a different `hostKey` are dropped — the write path's half of the + * cross-host leak guard. + */ +internal fun List.replacingHost( + hostKey: String, + records: List, +): List = removingHost(hostKey) + records.forHost(hostKey) + +/** A new list without records that already expired at [nowEpochMillis]. */ +internal fun List.droppingExpired(nowEpochMillis: Long): List = + filter { it.isLiveAt(nowEpochMillis) } diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStore.kt new file mode 100644 index 0000000..102d47d --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStore.kt @@ -0,0 +1,123 @@ +package wang.yaojia.webterm.hostregistry + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first + +/** + * Preferences-DataStore-backed [AuthCookieStore]. The whole cookie list is ONE JSON string + * ([AuthCookieCodec]) which is then **[AuthCookieCipher.seal]ed** before it is written, so the single + * stored value under [SEALED_COOKIES_KEY] is authenticated ciphertext, never the credential. + * + * ## Storage posture (plan §8) + * The stored `webterm_auth` cookie is a **full-shell credential** the server keeps alive for 30 days + * (`Max-Age=2592000`, `src/http/auth.ts`). Preferences DataStore is app-private but plaintext, and + * app-private is not a secrecy boundary against a rooted device, an `adb` pull of a debuggable build, + * a cloud backup or a device-to-device transfer. Plan §8 already requires the far weaker device-cert + * chain to be Tink-AEAD encrypted under an `AndroidKeyStore` master key, so this blob gets the SAME + * mechanism, injected through the [AuthCookieCipher] seam (`:client-tls-android` implements it; see + * that interface for why it is a seam and not a direct dependency). `android:allowBackup="false"` in + * the app manifest is the second, independent layer. + * + * ## Fail CLOSED, never fall back to plaintext + * If the cipher cannot seal (keystore unavailable, key lost after a restore onto a new device) the + * write persists **nothing** and additionally DELETES any previously stored blob, then reports the + * failure through [onCipherFailure]. The cost is that the user re-authenticates after a restart; the + * alternative — writing the credential in the clear — is the exact thing this class exists to prevent. + * A blob that fails to open is treated as "no stored cookies" for the same reason. + * + * The [DataStore] is injected (constructed from a Context in `:app` DI) so this class has no + * Android-framework surface of its own; the on-device behaviour is verified instrumented (androidTest) + * and the at-rest properties on the JVM (`DataStoreAuthCookieStoreCryptoTest`). + * + * @param cipher at-rest encryption for the serialized cookie list. Required — there is deliberately + * no default, so no wiring can end up persisting plaintext by omission. + * @param onCipherFailure invoked when a seal/open fails, i.e. when a credential was dropped instead of + * stored or restored. Never handed the cookie value. Callers must not log the [Throwable]'s payload + * beyond its type/message (the [AuthCookieCipher] contract keeps plaintext out of both), and must not + * throw — it runs inside the DataStore write transaction, so a throwing reporter would abort the write. + * @param now wall-clock source, injected so expiry-at-rest is testable. + */ +public class DataStoreAuthCookieStore( + private val dataStore: DataStore, + private val cipher: AuthCookieCipher, + private val onCipherFailure: (Throwable) -> Unit, + private val now: () -> Long = System::currentTimeMillis, +) : AuthCookieStore { + + override suspend fun loadAll(): List = decode(dataStore.data.first()[SEALED_COOKIES_KEY]) + + override suspend fun loadForHost(hostKey: String): List = + loadAll().forHost(hostKey) + + override suspend fun upsert(record: AuthCookieRecord): List = + writeTransform { it.upserting(record) } + + override suspend fun remove(id: AuthCookieId): List = + writeTransform { it.removing(id) } + + override suspend fun removeHost(hostKey: String): List = + writeTransform { it.removingHost(hostKey) } + + override suspend fun replaceHost( + hostKey: String, + records: List, + ): List = writeTransform { it.replacingHost(hostKey, records) } + + private suspend fun writeTransform( + transform: (List) -> List, + ): List { + lateinit var updated: List + dataStore.edit { prefs -> + // Decode drops expired records, so every write also garbage-collects dead credentials. + updated = transform(decode(prefs[SEALED_COOKIES_KEY])) + val sealed = if (updated.isEmpty()) null else seal(AuthCookieCodec.encode(updated)) + // sealed == null means either "nothing left to store" or "sealing failed" — both write + // NOTHING and drop the previous blob, so no unencrypted or superseded credential lingers. + if (sealed == null) prefs.remove(SEALED_COOKIES_KEY) else prefs[SEALED_COOKIES_KEY] = sealed + prefs.purgeLegacyPlaintext() + } + return updated + } + + /** Open + decode, dropping anything expired. An unopenable blob reads as "no cookies" (fail closed). */ + private fun decode(sealed: String?): List { + if (sealed.isNullOrBlank()) return emptyList() + val json = try { + cipher.open(sealed) + } catch (e: Exception) { + onCipherFailure(e) + return emptyList() + } + return AuthCookieCodec.decode(json, now()) + } + + /** Encrypt, or null when the cipher is unavailable — the caller then persists nothing. */ + private fun seal(json: String): String? = + try { + cipher.seal(json) + } catch (e: Exception) { + onCipherFailure(e) + null + } + + /** + * Belt-and-braces: an early build of this store wrote the cookie list to [LEGACY_PLAINTEXT_KEY] + * unencrypted. That key is never read, and is removed by the first write, so a plaintext credential + * cannot outlive the upgrade on a device that ran such a build. + */ + private fun MutablePreferences.purgeLegacyPlaintext() { + remove(LEGACY_PLAINTEXT_KEY) // absent → no-op, and DataStore skips the write when nothing changed + } + + private companion object { + /** The one stored value: `AuthCookieCipher.seal(AuthCookieCodec.encode(records))`. */ + val SEALED_COOKIES_KEY = stringPreferencesKey("authCookiesSealed") + + /** Pre-encryption key — write-purged, never read. Do not reuse this name. */ + val LEGACY_PLAINTEXT_KEY = stringPreferencesKey("authCookies") + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStore.kt new file mode 100644 index 0000000..92b94a9 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStore.kt @@ -0,0 +1,65 @@ +package wang.yaojia.webterm.hostregistry + +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 + +/** + * Preferences-DataStore-backed [SessionWatermarkStore]. The whole watermark map is ONE JSON + * string under [WATERMARKS_KEY] ([SessionWatermarkCodec]) — the same shape [DataStoreHostStore] + * uses for `"hosts"`, and for the same reason: the cap and the sanitize pass have to apply to + * the map as a whole, which a per-session key prefix (the [DataStoreLastSessionStore] shape) + * cannot do without scanning every key on every write and still leaking orphaned keys. + * + * The key name mirrors the iOS `UserDefaults` key `"unreadWatermarks"`. It is **disjoint** from + * every other key over this file (`"hosts"`, `"lastSessionId."`, `"authCookiesSealed"`), + * because ONE Preferences DataStore instance is shared app-wide (`:app` `StorageModule`) — a + * second instance over the same file would corrupt it, so never open one. + * + * Reads decode then [forStorage] (validate + cap): the blob is untrusted at rest. Writes are an + * atomic read-modify-write via [DataStore.edit], and an empty result REMOVES the key rather than + * writing `{}`, so the file shrinks back to nothing. + * + * The [DataStore] is injected (constructed from a Context in `:app` DI) so this class has no + * Android-framework surface of its own; the on-device behaviour — including survival across a + * fresh store instance, i.e. process death — is verified instrumented (androidTest, plan §7). + * Not encrypted, deliberately: see the posture note on [SessionWatermarkStore]. + */ +public class DataStoreSessionWatermarkStore( + private val dataStore: DataStore, +) : SessionWatermarkStore { + + override suspend fun loadAll(): Map = + SessionWatermarkCodec.decode(dataStore.data.first()[WATERMARKS_KEY]).forStorage() + + override suspend fun replaceAll(watermarks: Map): Map = + writeTransform { watermarks.forStorage() } + + override suspend fun remove(sessionId: String): Map = + writeTransform { it.removingWatermark(sessionId) } + + private suspend fun writeTransform( + transform: (Map) -> Map, + ): Map { + lateinit var updated: Map + dataStore.edit { prefs -> + // Decoding through forStorage() means every write also garbage-collects invalid + // rows and re-applies the cap, even when the transform itself only removes. + val current = SessionWatermarkCodec.decode(prefs[WATERMARKS_KEY]).forStorage() + updated = transform(current) + if (updated.isEmpty()) { + prefs.remove(WATERMARKS_KEY) + } else { + prefs[WATERMARKS_KEY] = SessionWatermarkCodec.encode(updated) + } + } + return updated + } + + private companion object { + /** The one stored value: `SessionWatermarkCodec.encode(watermarks)`. */ + val WATERMARKS_KEY = stringPreferencesKey("unreadWatermarks") + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStore.kt new file mode 100644 index 0000000..e4fdace --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStore.kt @@ -0,0 +1,48 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * In-memory [AuthCookieStore]. Lives in `main` (not `test`) on purpose — it stands in for the + * DataStore store in this module's contract tests AND in the AW4 wiring/ViewModel tests, exactly + * like [InMemoryHostStore]. + * + * A [Mutex] serialises each read-modify-write; the state is a value snapshot replaced wholesale on + * every change (no in-place mutation of a shared reference). + */ +public class InMemoryAuthCookieStore( + initial: List = emptyList(), +) : AuthCookieStore { + private val mutex = Mutex() + + // Defensive copy so an external mutable list handed to the ctor can't alias our state. + private var records: List = initial.toList() + + override suspend fun loadAll(): List = mutex.withLock { records } + + override suspend fun loadForHost(hostKey: String): List = + mutex.withLock { records.forHost(hostKey) } + + override suspend fun upsert(record: AuthCookieRecord): List = + write { it.upserting(record) } + + override suspend fun remove(id: AuthCookieId): List = + write { it.removing(id) } + + override suspend fun removeHost(hostKey: String): List = + write { it.removingHost(hostKey) } + + override suspend fun replaceHost( + hostKey: String, + records: List, + ): List = write { it.replacingHost(hostKey, records) } + + private suspend fun write( + transform: (List) -> List, + ): List = mutex.withLock { + val updated = transform(records) + records = updated + updated + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStore.kt new file mode 100644 index 0000000..1d18f6c --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStore.kt @@ -0,0 +1,40 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * In-memory [SessionWatermarkStore]. Lives in `main` (not `test`) on purpose — it stands in for + * the DataStore store in this module's contract tests AND in the AW4 ViewModel tests, exactly + * like [InMemoryHostStore] / [InMemoryAuthCookieStore]. + * + * It applies the SAME [forStorage] rules (validation + cap) as [DataStoreSessionWatermarkStore]; + * a laxer double would let a ViewModel test pass against behaviour no device exhibits. + * + * A [Mutex] serialises each read-modify-write; the state is a value snapshot replaced wholesale + * on every change (no in-place mutation of a shared reference). + */ +public class InMemorySessionWatermarkStore( + initial: Map = emptyMap(), +) : SessionWatermarkStore { + private val mutex = Mutex() + + // forStorage() always builds a fresh map, so a mutable map handed to the ctor cannot alias state. + private var stored: Map = initial.forStorage() + + override suspend fun loadAll(): Map = mutex.withLock { stored } + + override suspend fun replaceAll(watermarks: Map): Map = + write { watermarks.forStorage() } + + override suspend fun remove(sessionId: String): Map = + write { it.removingWatermark(sessionId) } + + private suspend fun write( + transform: (Map) -> Map, + ): Map = mutex.withLock { + val updated = transform(stored) + stored = updated + updated + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodec.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodec.kt new file mode 100644 index 0000000..4321655 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodec.kt @@ -0,0 +1,33 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Pure JSON codec for the persisted watermark map (shared by [DataStoreSessionWatermarkStore], + * JVM-testable without a Context). Mirrors [HostCodec]: encode is total, decode is defensive — + * a corrupt/undecodable blob reads back as "no watermarks" so a cold start can never crash on + * a bad row. + * + * Deliberately dumb about *meaning*: it does not validate session ids or instants. That is + * [sanitizedWatermarks]' job, so exactly one place owns the trust decision (and the in-memory + * double gets the identical treatment without touching JSON). + */ +internal object SessionWatermarkCodec { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun encode(watermarks: Map): String = json.encodeToString(watermarks) + + fun decode(raw: String?): Map { + if (raw.isNullOrBlank()) return emptyMap() + return try { + json.decodeFromString>(raw) + } catch (_: Exception) { + emptyMap() // corrupted blob at rest → start clean, never crash + } + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkStore.kt new file mode 100644 index 0000000..2e429fa --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkStore.kt @@ -0,0 +1,151 @@ +package wang.yaojia.webterm.hostregistry + +import wang.yaojia.webterm.wire.Validation + +/** + * Durable storage for the unread **watermarks** — `sessionId → last-seen instant (ms since + * epoch)` — that decide the session-list unread dot. Ports the iOS + * `UnreadWatermarkStore` / `UserDefaultsUnreadWatermarkStore` pair (plan §2 lists "unread + * watermarks" under *Persistence (non-secret)*). + * + * Implementations: [DataStoreSessionWatermarkStore] (real, Preferences-DataStore-backed) and + * [InMemorySessionWatermarkStore] (in-`main` double, exactly like [InMemoryHostStore] / + * [InMemoryAuthCookieStore]). + * + * ## Why this exists at all + * Without it the dot resets whenever the process dies — which is precisely when it matters: + * the product premise is walking away and coming back, and a dot that only survives while + * the app is warm answers a question the user never asked. + * + * ## Division of labour with `UnreadLedger` (do not duplicate it) + * The *reducer* — monotonic `record`, the `isUnread` comparison, the tie-broken cap — lives + * in `:session-core`'s `UnreadLedger`, which is deliberately persistence-agnostic. This + * module cannot see it (`:host-registry` depends only on `:wire-protocol`; nothing points + * sideways — see `android/README.md`), and must not restate it. So the contract here is + * plain-map I/O, like the iOS store's `[UUID: Int]`: `:app` owns the ledger, hands its + * `watermarks` map to [replaceAll], and rebuilds a ledger from [loadAll] on cold start. + * + * ## At-rest posture — no Tink here, deliberately + * The neighbouring [AuthCookieStore] blob IS Tink-AEAD-sealed, so a reader will reasonably + * ask why this one is not. Because it is not a credential and not private data: a watermark + * is a UUID the device already stores in the clear (the host list, the last-session id) plus + * a coarse timestamp of when the user last *looked* at a session. Sealing it would buy + * nothing an attacker with app-private read access does not already have, while adding a + * fail-closed dependency on `AndroidKeyStore` — i.e. a keystore hiccup would silently reset + * unread state. Plan §2's split is explicit: secrets get the Tink/AndroidKeyStore tier, + * non-secret UI state gets plain Preferences DataStore. This is the second tier, same as + * [HostStore] and [LastSessionStore]. (`android:allowBackup="false"` still applies to the + * whole file.) + * + * ## Untrusted at rest + * Persisted ids are re-validated on every read with the frozen + * [Validation.isValidSessionId] — the SAME v4-specific guard the wire codec applies to + * server-issued ids, **not** a format-only `UUID.fromString` (that mistake was already made + * once in [DataStoreLastSessionStore] and had to be corrected; do not repeat it). + * + * Immutable style throughout, like [HostStore]: mutations RETURN the new map instead of + * mutating shared state, an unknown id is an explicit no-op, and encounter order is + * preserved (only the cap may reorder — see [cappedWatermarks]). + */ +public interface SessionWatermarkStore { + /** + * Every stored watermark, already sanitized and capped ([forStorage]). Empty when + * nothing is stored or the blob no longer decodes — never throws. + */ + public suspend fun loadAll(): Map + + /** + * Swap the whole map for [watermarks] — the write `:app` drives after a `markSeen` + * ("this is the ledger now"). A wholesale swap, NOT a merge: the in-memory `UnreadLedger` + * is the source of truth, so a merge here could resurrect an entry the ledger dropped. + * Returns exactly what was stored (i.e. [watermarks].[forStorage]), which may be a + * subset of the argument. + */ + public suspend fun replaceAll(watermarks: Map): Map + + /** + * Forget [sessionId]'s watermark — the eager GC hook for a session that ended (`exit` + * frame / a kill), matching how [LastSessionStore] is cleared on `.exited`. An unknown + * or malformed id is an explicit no-op returning the unchanged map, never a throw. + */ + public suspend fun remove(sessionId: String): Map +} + +// ── Pure immutable transforms shared by every SessionWatermarkStore ─────────────────────── +// (DRY, mirrors HostStore.kt / AuthCookieStore.kt). Never mutate the receiver — always +// return a fresh map. `internal` so both stores and the same-module JVM tests can use them +// without widening the public surface. + +/** + * Hard ceiling on stored watermarks. **This is what bounds growth** (see the GC note below) + * and mirrors `UnreadLedger.MAX_ENTRIES` in `:session-core`, which cannot be imported here + * (no sideways module edge). Keep the two numbers equal: the ledger caps what `:app` holds, + * this caps what reaches the disk, and a store that trusted the caller would be no bound at + * all. + * + * ### GC rule (the decision, stated explicitly) + * Sessions are ephemeral, so a watermark can outlive its session forever. Growth is bounded + * by **three** rules, in order of preference: + * 1. **Eager** — [SessionWatermarkStore.remove] on a session that exits or is killed. This + * is the only rule that frees an entry the moment it is provably dead. + * 2. **Structural** — this count cap. Over [MAX_WATERMARKS], the OLDEST-seen entries are + * dropped ([cappedWatermarks]), so the file can never exceed ~512 · ~50 B ≈ 25 KB no + * matter how many sessions a device sees or how many exits it misses (force-stop, crash, + * a host killed from another device). This is the backstop that actually guarantees the + * bound. + * 3. **Hygiene** — [sanitizedWatermarks] drops rows that no longer validate, so a format + * change or a corrupted row cannot accumulate either. + * + * Deliberately NOT a rule: pruning to the ids returned by the latest `GET /live-sessions`. + * That looks tempting and is wrong — the fetch covers ONE host, so it would wipe every other + * host's watermarks on a host switch, and an offline/erroring host returns nothing at all + * (iOS records the same decision in `SessionListViewModel`). + * + * Also deliberately NOT a rule: a staleness/TTL sweep. Dropping an old watermark re-lights + * the dot for a session that is still alive and still unchanged — a *false* unread, which is + * worse than 25 KB. + */ +internal const val MAX_WATERMARKS: Int = 512 + +/** + * Drop everything untrustworthy: ids that are not valid wire session ids + * ([Validation.isValidSessionId]) and non-positive instants (`<= 0` means "never seen", which + * is already the default for a missing entry — storing it is pure waste). Valid ids are + * canonicalised to lower case and case-variant spellings of the same id collapse to their + * newest instant (they are the same session; `Validation` is case-insensitive, `UUID.toString` + * is lower case, and merging by max can never lower a watermark). Encounter order is kept. + */ +internal fun Map.sanitizedWatermarks(): Map { + val out = LinkedHashMap(size) + for ((id, atMs) in this) { + if (atMs <= 0L || !Validation.isValidSessionId(id)) continue + val key = id.lowercase() + val existing = out[key] + out[key] = if (existing == null) atMs else maxOf(existing, atMs) + } + return out +} + +/** + * Keep at most the [MAX_WATERMARKS] NEWEST watermarks, tie-broken by session id so the + * outcome is deterministic across runs (same rule as `UnreadLedger.capped`). Under the cap + * the receiver's own order is returned untouched; over it the result is newest-first, since a + * map that had to be truncated has no meaningful insertion order left to preserve. + */ +internal fun Map.cappedWatermarks(): Map { + if (size <= MAX_WATERMARKS) return this + return entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .take(MAX_WATERMARKS) + .associate { it.key to it.value } +} + +/** A new map without [sessionId]'s watermark (case-insensitive). Unknown id → unchanged contents. */ +internal fun Map.removingWatermark(sessionId: String): Map { + val target = sessionId.lowercase() + return filterKeys { it.lowercase() != target } +} + +/** The single write/read gate: sanitize, then cap. Everything crossing storage goes through it. */ +internal fun Map.forStorage(): Map = + sanitizedWatermarks().cappedWatermarks() diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodecTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodecTest.kt new file mode 100644 index 0000000..46099a3 --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodecTest.kt @@ -0,0 +1,98 @@ +package wang.yaojia.webterm.hostregistry + +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 + +/** + * At-rest encoding for the auth-cookie list. Cookie records are UNTRUSTED at rest (same posture as + * `HostCodec`): a corrupt blob starts clean, a structurally invalid record is dropped, and an + * already-expired record is dropped on read so a stale shell credential is never resurrected after + * a long background. + */ +class AuthCookieCodecTest { + + private fun record( + hostKey: String = "http://192.168.1.5:3000", + name: String = "webterm_auth", + expiresAtEpochMillis: Long = FAR_FUTURE, + ) = AuthCookieRecord( + hostKey = hostKey, + name = name, + value = TOKEN, + domain = "192.168.1.5", + path = "/", + expiresAtEpochMillis = expiresAtEpochMillis, + secure = false, + httpOnly = true, + hostOnly = true, + ) + + @Test + fun roundTripsEveryFieldOfALiveRecord() { + // Arrange + val original = listOf(record(), record(hostKey = "https://tunnel.example:443", name = "other")) + + // Act + val decoded = AuthCookieCodec.decode(AuthCookieCodec.encode(original), NOW) + + // Assert + assertEquals(original, decoded) + } + + @Test + fun decodingACorruptBlobStartsClean() { + assertEquals(emptyList(), AuthCookieCodec.decode("{not json", NOW)) + } + + @Test + fun decodingAnAbsentOrBlankBlobIsEmpty() { + assertEquals(emptyList(), AuthCookieCodec.decode(null, NOW)) + assertEquals(emptyList(), AuthCookieCodec.decode(" ", NOW)) + } + + @Test + fun dropsARecordWithABlankHostKeyOrName() { + // Arrange: a record with no host key could otherwise be replayed to ANY host. + val raw = AuthCookieCodec.encode(listOf(record(hostKey = ""), record(name = ""), record())) + + // Act + val decoded = AuthCookieCodec.decode(raw, NOW) + + // Assert + assertEquals(1, decoded.size) + assertEquals("http://192.168.1.5:3000", decoded.single().hostKey) + } + + @Test + fun dropsARecordThatExpiredWhileAtRest() { + // Arrange + val raw = AuthCookieCodec.encode(listOf(record(expiresAtEpochMillis = NOW), record(name = "live"))) + + // Act + val decoded = AuthCookieCodec.decode(raw, NOW) + + // Assert + assertEquals(listOf("live"), decoded.map { it.name }) + } + + @Test + fun theEncodedBlobIsTheOnlyPlaceTheCredentialAppears() { + // Arrange + val records = listOf(record()) + + // Act + val rendered = records.toString() + records.single().toString() + AuthCookieCodec.toString() + + // Assert: the blob must hold the value (it IS the persisted credential), but no toString may. + assertTrue(AuthCookieCodec.encode(records).contains(TOKEN)) + assertFalse(rendered.contains(TOKEN), "credential leaked into a toString path: $rendered") + } + + private companion object { + const val TOKEN = "s3cr3t-webterm-token-value" + const val NOW = 1_800_000_000_000L + const val FAR_FUTURE = 4_000_000_000_000L + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStoreTransformsTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStoreTransformsTest.kt new file mode 100644 index 0000000..928a2bb --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStoreTransformsTest.kt @@ -0,0 +1,172 @@ +package wang.yaojia.webterm.hostregistry + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The pure immutable list transforms behind every [AuthCookieStore] — same discipline as the + * `HostStore` transforms: never mutate the receiver, upsert preserves position, an unknown id is + * an explicit no-op. Host isolation is a transform-level property here: `forHost` NEVER returns a + * record filed under a different host key (a shell credential — a cross-host leak is a security + * bug), and `removingHost` never touches another host's records. + */ +class AuthCookieStoreTransformsTest { + + private val hostA = "http://192.168.1.5:3000" + private val hostB = "https://h7fd8.terminal.yaojia.wang:443" + + private fun record( + hostKey: String, + name: String = "webterm_auth", + value: String = "token-$hostKey", + path: String = "/", + expiresAtEpochMillis: Long = FAR_FUTURE, + ) = AuthCookieRecord( + hostKey = hostKey, + name = name, + value = value, + domain = hostKey.substringAfter("://").substringBefore(":"), + path = path, + expiresAtEpochMillis = expiresAtEpochMillis, + ) + + @Test + fun upsertingAppendsAnUnknownRecordAndLeavesTheReceiverUntouched() { + // Arrange + val original = listOf(record(hostA)) + + // Act + val updated = original.upserting(record(hostB)) + + // Assert + assertEquals(1, original.size, "the receiver must never be mutated") + assertEquals(listOf(hostA, hostB), updated.map { it.hostKey }) + assertNotSame(original, updated) + } + + @Test + fun upsertingReplacesTheSameIdInPlacePreservingPosition() { + // Arrange + val original = listOf(record(hostA, value = "old"), record(hostB)) + + // Act + val updated = original.upserting(record(hostA, value = "new")) + + // Assert + assertEquals(listOf(hostA, hostB), updated.map { it.hostKey }) + assertEquals("new", updated.first().value) + assertEquals("old", original.first().value) + } + + @Test + fun aDifferentCookieNameOnTheSameHostIsASeparateRecord() { + // Arrange + val original = listOf(record(hostA, name = "webterm_auth")) + + // Act + val updated = original.upserting(record(hostA, name = "other")) + + // Assert: identity is (hostKey, domain, path, name) — RFC 6265's key plus our host scope. + assertEquals(2, updated.size) + } + + @Test + fun removingAnUnknownIdIsANoOp() { + // Arrange + val original = listOf(record(hostA)) + + // Act + val updated = original.removing(record(hostB).id) + + // Assert + assertEquals(original, updated) + } + + @Test + fun removingDropsOnlyTheMatchingRecord() { + // Arrange + val target = record(hostA) + val original = listOf(target, record(hostB)) + + // Act + val updated = original.removing(target.id) + + // Assert + assertEquals(listOf(hostB), updated.map { it.hostKey }) + assertEquals(2, original.size) + } + + @Test + fun forHostReturnsOnlyThatHostsRecords() { + // Arrange + val original = listOf(record(hostA), record(hostB), record(hostA, name = "second")) + + // Act + val forA = original.forHost(hostA) + + // Assert + assertTrue(forA.all { it.hostKey == hostA }, "a host's cookies must never include another host's") + assertEquals(2, forA.size) + } + + @Test + fun removingHostClearsOneHostAndLeavesTheOtherIntact() { + // Arrange + val original = listOf(record(hostA), record(hostB), record(hostA, name = "second")) + + // Act + val updated = original.removingHost(hostA) + + // Assert + assertEquals(listOf(hostB), updated.map { it.hostKey }) + } + + @Test + fun replacingHostSwapsOneHostsRecordsWholesale() { + // Arrange + val original = listOf(record(hostA, value = "stale"), record(hostB)) + + // Act + val updated = original.replacingHost(hostA, listOf(record(hostA, value = "fresh"))) + + // Assert + assertEquals(setOf(hostA, hostB), updated.map { it.hostKey }.toSet()) + assertEquals("fresh", updated.single { it.hostKey == hostA }.value) + } + + @Test + fun replacingHostRejectsRecordsFiledUnderADifferentHost() { + // Arrange: a mis-wired caller must not be able to smuggle host B's cookie under host A. + val original = listOf(record(hostB)) + + // Act + val updated = original.replacingHost(hostA, listOf(record(hostB, value = "smuggled"))) + + // Assert + assertFalse( + updated.any { it.hostKey == hostB && it.value == "smuggled" }, + "replacingHost must drop records whose hostKey != the host being replaced", + ) + } + + @Test + fun droppingExpiredKeepsOnlyLiveRecords() { + // Arrange + val live = record(hostA, expiresAtEpochMillis = NOW + 1) + val expired = record(hostB, expiresAtEpochMillis = NOW) + + // Act + val updated = listOf(live, expired).droppingExpired(NOW) + + // Assert: expiry is inclusive — at the instant of expiry the cookie is already dead. + assertEquals(listOf(live), updated) + } + + private companion object { + const val NOW = 1_800_000_000_000L + const val FAR_FUTURE = 4_000_000_000_000L + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreCryptoTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreCryptoTest.kt new file mode 100644 index 0000000..1837ebf --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreCryptoTest.kt @@ -0,0 +1,292 @@ +package wang.yaojia.webterm.hostregistry + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.preferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey +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.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * The AT-REST posture of [DataStoreAuthCookieStore] — what actually lands in the backing store. + * + * The persisted `webterm_auth` cookie is a **full-shell credential** with a 30-day server-side + * `Max-Age` (`src/http/auth.ts`), so plan §8's "encrypted, never in the clear" rule for the far + * weaker device-cert chain applies to it at least as strongly. These are the tests that would go red + * if the store ever wrote the value where a rooted device, an `adb` pull of a debug build, a cloud + * backup or a device-to-device transfer could read it. + * + * These run on the JVM (no Context) against a recording [DataStore] double, so the exact stored + * `Preferences` can be inspected byte-for-byte. The real Tink/AndroidKeyStore cipher is exercised + * instrumented in `:client-tls-android` (`TinkAuthCookieCipherTest`), and the DataStore file itself is + * checked on-device in `DataStoreAuthCookieStoreTest`. + */ +@DisplayName("DataStoreAuthCookieStore — at-rest encryption") +class DataStoreAuthCookieStoreCryptoTest { + + private val hostA = "http://192.168.1.5:3000" + private val hostB = "https://tunnel.example:443" + + private fun record( + hostKey: String = hostA, + name: String = "webterm_auth", + value: String = SECRET, + expiresAt: Long = FAR_FUTURE, + ) = AuthCookieRecord( + hostKey = hostKey, + name = name, + value = value, + domain = "192.168.1.5", + path = "/", + expiresAtEpochMillis = expiresAt, + httpOnly = true, + ) + + // ── The properties that matter ──────────────────────────────────────────────────────────────── + + @Test + @DisplayName("the cookie value never lands in the backing store in any recoverable form") + fun theCookieValueIsNeverStoredRecoverably() = runTest { + // Arrange + val dataStore = RecordingPreferencesDataStore() + val store = newStore(dataStore) + + // Act + store.upsert(record()) + + // Assert: nothing anywhere in the persisted Preferences reveals the credential — not raw, not + // Base64'd, not hex, not percent-encoded (a "cipher" that merely re-encodes must fail here). + val onDisk = dataStore.dump() + assertTrue(onDisk.isNotEmpty(), "the store wrote nothing at all — the round-trip test guards that") + recoverableFormsOf(SECRET).forEach { form -> + assertFalse( + onDisk.contains(form), + "a shell credential leaked into the backing store as <$form>: $onDisk", + ) + } + } + + @Test + @DisplayName("what is stored is the sealed blob, and it round-trips back through the cipher") + fun theSealedBlobRoundTrips() = runTest { + // Arrange + val dataStore = RecordingPreferencesDataStore() + val cipher = ReversibleTestCipher() + val a = record() + val b = record(hostKey = hostB, value = "other-host-token") + + // Act + newStore(dataStore, cipher).upsert(a) + newStore(dataStore, cipher).upsert(b) + val reloaded = newStore(dataStore, cipher) + + // Assert: a cold-start store over the same bytes sees both cookies, each on its own host. + assertEquals(listOf(a, b), reloaded.loadAll()) + assertEquals(listOf(a), reloaded.loadForHost(hostA)) + // ...and the ciphertext is an encryption OF the codec's JSON, not a per-field mangling. + assertEquals(AuthCookieCodec.encode(listOf(a, b)), cipher.open(dataStore.sealedBlob()!!)) + } + + @Test + @DisplayName("fail closed: when the cipher cannot seal, NOTHING is persisted (no plaintext fallback)") + fun aSealFailurePersistsNothing() = runTest { + // Arrange + val dataStore = RecordingPreferencesDataStore() + val failures = mutableListOf() + val store = newStore( + dataStore, + ReversibleTestCipher(failSeal = true), + onCipherFailure = { failures += it }, + ) + + // Act + val updated = store.upsert(record()) + + // Assert: the in-memory answer is still correct (this process keeps working)... + assertEquals(listOf(record()), updated) + // ...but the credential never reached the disk, and the failure was reported, not swallowed. + assertEquals(emptyMap, Any>(), dataStore.current().asMap()) + assertEquals(1, failures.size, "a dropped persistence must be reported to the wiring") + } + + @Test + @DisplayName("fail closed: a previously sealed blob is DELETED when sealing starts failing") + fun aSealFailureAlsoDropsTheStaleBlob() = runTest { + // Arrange: a good cipher persisted host A's cookie... + val dataStore = RecordingPreferencesDataStore() + val cipher = ReversibleTestCipher() + newStore(dataStore, cipher).upsert(record()) + assertTrue(dataStore.sealedBlob() != null, "arrange failed: nothing was sealed") + + // Act: ...then the keystore breaks (open still works, seal does not) and the set changes. + newStore(dataStore, ReversibleTestCipher(failSeal = true, failOpen = false, key = cipher.key)) + .replaceHost(hostA, listOf(record(value = "rotated"))) + + // Assert: the superseded blob is gone rather than left behind as a resurrectable credential. + assertEquals(null, dataStore.sealedBlob()) + } + + @Test + @DisplayName("an unreadable blob (tamper / key lost with the device) reads as no cookies") + fun anUnreadableBlobYieldsNoCookies() = runTest { + // Arrange: sealed by a cipher whose key this store does not have. + val dataStore = RecordingPreferencesDataStore() + newStore(dataStore, ReversibleTestCipher(key = 0x5A)).upsert(record()) + val failures = mutableListOf() + val store = newStore( + dataStore, + ReversibleTestCipher(failOpen = true), + onCipherFailure = { failures += it }, + ) + + // Act + val loaded = store.loadAll() + + // Assert + assertEquals(emptyList(), loaded) + assertEquals(1, failures.size, "an unopenable credential blob must be reported") + } + + @Test + @DisplayName("a legacy PLAINTEXT blob is never read, and is deleted by the next write") + fun aLegacyPlaintextBlobIsPurged() = runTest { + // Arrange: what a pre-encryption build would have left behind under the old key. + val legacyKey = stringPreferencesKey("authCookies") + val dataStore = RecordingPreferencesDataStore( + preferencesOf(legacyKey to AuthCookieCodec.encode(listOf(record()))), + ) + val store = newStore(dataStore) + + // Act + Assert: never read... + assertEquals(emptyList(), store.loadAll()) + + // ...and the plaintext is gone after one write, so it cannot outlive the upgrade. + store.upsert(record(value = "fresh")) + assertEquals(null, dataStore.current()[legacyKey]) + assertFalse(dataStore.dump().contains(SECRET), "the legacy plaintext survived: ${dataStore.dump()}") + } + + @Test + @DisplayName("clearing the last cookie removes the blob instead of storing an empty one") + fun clearingRemovesTheBlob() = runTest { + // Arrange + val dataStore = RecordingPreferencesDataStore() + val cipher = ReversibleTestCipher() + val store = newStore(dataStore, cipher) + store.upsert(record()) + + // Act + store.removeHost(hostA) + + // Assert + assertEquals(null, dataStore.sealedBlob()) + } + + @Test + @DisplayName("a record that expired at rest is dropped on read and garbage-collected on write") + fun expiryAtRestStillApplies() = runTest { + // Arrange: sealed while live, read back after the clock passed its expiry. + val dataStore = RecordingPreferencesDataStore() + val cipher = ReversibleTestCipher() + val expiresAt = 1_800_000_000_000L + newStore(dataStore, cipher, now = { expiresAt - 1 }).upsert(record(expiresAt = expiresAt)) + + // Act + val afterExpiry = newStore(dataStore, cipher, now = { expiresAt + 1 }) + + // Assert + assertEquals(emptyList(), afterExpiry.loadAll()) + assertEquals( + listOf(hostB), + afterExpiry.upsert(record(hostKey = hostB)).map { it.hostKey }, + "a write must also garbage-collect the expired credential, not just hide it on read", + ) + } + + // ── Doubles ────────────────────────────────────────────────────────────────────────────────── + + private fun newStore( + dataStore: DataStore, + cipher: AuthCookieCipher = ReversibleTestCipher(), + now: () -> Long = { NOW }, + onCipherFailure: (Throwable) -> Unit = {}, + ) = DataStoreAuthCookieStore( + dataStore = dataStore, + cipher = cipher, + onCipherFailure = onCipherFailure, + now = now, + ) + + /** + * In-memory [DataStore] that keeps the exact [Preferences] the store wrote, so a test can inspect + * every key and value. Not a cipher test double — the real DataStore is byte-identical in what it + * is HANDED, which is the only thing these tests assert about. + */ + private class RecordingPreferencesDataStore( + initial: Preferences = emptyPreferences(), + ) : DataStore { + private val state = MutableStateFlow(initial) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences { + val updated = transform(state.value) + state.value = updated + return updated + } + + fun current(): Preferences = state.value + + fun sealedBlob(): String? = state.value[stringPreferencesKey("authCookiesSealed")] + + /** Every key AND value as one haystack — catches a leak under ANY key, not just the known one. */ + fun dump(): String = state.value.asMap().entries.joinToString(";") { "${it.key.name}=${it.value}" } + } + + /** + * A reversible stand-in for the real Tink AEAD: XOR under a byte key, then Base64. Strong enough + * for what these tests assert (the stored form is neither the plaintext nor an encoding of it, and + * a different key cannot open it) and weak on purpose — it lives in the TEST source set so it can + * never be wired into the app. [failSeal]/[failOpen] drive the fail-closed paths. + */ + private class ReversibleTestCipher( + private val failSeal: Boolean = false, + private val failOpen: Boolean = false, + val key: Byte = 0x2C, + ) : AuthCookieCipher { + override fun seal(plaintext: String): String { + if (failSeal) throw IllegalStateException("test cipher: keystore unavailable") + return java.util.Base64.getEncoder().encodeToString(plaintext.toByteArray().xored()) + } + + override fun open(sealed: String): String { + if (failOpen) throw IllegalStateException("test cipher: blob failed authentication") + return String(java.util.Base64.getDecoder().decode(sealed).xored()) + } + + private fun ByteArray.xored(): ByteArray = ByteArray(size) { (this[it].toInt() xor key.toInt()).toByte() } + } + + private companion object { + /** Distinctive so a leak is unambiguous, and long enough that a partial match is meaningful. */ + const val SECRET = "SUPER_SECRET_SHELL_TOKEN_9f3a7c11" + const val NOW = 1_700_000_000_000L + const val FAR_FUTURE = 4_000_000_000_000L + + /** Every encoding a naive "protection" might leave the credential recoverable through. */ + fun recoverableFormsOf(secret: String): List = listOf( + secret, + java.util.Base64.getEncoder().encodeToString(secret.toByteArray()), + java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(secret.toByteArray()), + secret.toByteArray().joinToString("") { "%02x".format(it) }, + java.net.URLEncoder.encode(secret, "UTF-8"), + ) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStoreTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStoreTest.kt new file mode 100644 index 0000000..e7ce8d9 --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStoreTest.kt @@ -0,0 +1,124 @@ +package wang.yaojia.webterm.hostregistry + +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 + +/** + * The [AuthCookieStore] contract, exercised through the in-memory double that ships in `main` + * (it doubles for the DataStore store in the AW4 wiring tests, exactly like [InMemoryHostStore]). + */ +class InMemoryAuthCookieStoreTest { + + private val hostA = "http://192.168.1.5:3000" + private val hostB = "https://tunnel.example:443" + + private fun record(hostKey: String, name: String = "webterm_auth", value: String = "v") = + AuthCookieRecord( + hostKey = hostKey, + name = name, + value = value, + domain = "example", + path = "/", + expiresAtEpochMillis = FAR_FUTURE, + ) + + @Test + fun upsertReturnsTheNewListAndIsReadableBack() = runTest { + // Arrange + val store = InMemoryAuthCookieStore() + + // Act + val afterFirst = store.upsert(record(hostA)) + val afterSecond = store.upsert(record(hostB)) + + // Assert + assertEquals(1, afterFirst.size) + assertEquals(listOf(hostA, hostB), afterSecond.map { it.hostKey }) + assertEquals(afterSecond, store.loadAll()) + } + + @Test + fun upsertReplacesTheSameRecordInPlace() = runTest { + // Arrange + val store = InMemoryAuthCookieStore(listOf(record(hostA, value = "old"), record(hostB))) + + // Act + val updated = store.upsert(record(hostA, value = "new")) + + // Assert + assertEquals(listOf(hostA, hostB), updated.map { it.hostKey }) + assertEquals("new", updated.first().value) + } + + @Test + fun loadForHostNeverLeaksAnotherHostsCookie() = runTest { + // Arrange + val store = InMemoryAuthCookieStore(listOf(record(hostA), record(hostB))) + + // Act + val forA = store.loadForHost(hostA) + + // Assert + assertEquals(1, forA.size) + assertTrue(forA.all { it.hostKey == hostA }) + } + + @Test + fun removeAnUnknownIdIsANoOp() = runTest { + // Arrange + val store = InMemoryAuthCookieStore(listOf(record(hostA))) + + // Act + val updated = store.remove(record(hostB).id) + + // Assert + assertEquals(1, updated.size) + assertEquals(updated, store.loadAll()) + } + + @Test + fun removeHostClearsEveryCookieForThatHostOnly() = runTest { + // Arrange + val store = InMemoryAuthCookieStore( + listOf(record(hostA), record(hostA, name = "second"), record(hostB)), + ) + + // Act + val updated = store.removeHost(hostA) + + // Assert + assertEquals(listOf(hostB), updated.map { it.hostKey }) + assertEquals(emptyList(), store.loadForHost(hostA)) + } + + @Test + fun replaceHostSwapsThatHostsCookiesWholesale() = runTest { + // Arrange: this is the write the OkHttp cookie jar drives — "here is host A's cookie set now". + val store = InMemoryAuthCookieStore(listOf(record(hostA, value = "stale"), record(hostB))) + + // Act + val updated = store.replaceHost(hostA, listOf(record(hostA, value = "fresh"))) + + // Assert + assertEquals("fresh", updated.single { it.hostKey == hostA }.value) + assertEquals(1, updated.count { it.hostKey == hostB }) + } + + @Test + fun replaceHostWithAnEmptyListClearsThatHost() = runTest { + // Arrange: the server expired the cookie (Max-Age=0) → the jar reports an empty set. + val store = InMemoryAuthCookieStore(listOf(record(hostA), record(hostB))) + + // Act + val updated = store.replaceHost(hostA, emptyList()) + + // Assert + assertEquals(listOf(hostB), updated.map { it.hostKey }) + } + + private companion object { + const val FAR_FUTURE = 4_000_000_000_000L + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStoreTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStoreTest.kt new file mode 100644 index 0000000..056ae0f --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStoreTest.kt @@ -0,0 +1,77 @@ +package wang.yaojia.webterm.hostregistry + +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 + +/** + * Contract tests for the in-`main` [InMemorySessionWatermarkStore] double (the same role + * [InMemoryHostStore] / [InMemoryAuthCookieStore] play: it stands in for the DataStore + * store in the AW4 ViewModel tests). The double must enforce the SAME at-rest rules as + * the real store, otherwise a ViewModel test can pass against behaviour the device never + * exhibits. + */ +class InMemorySessionWatermarkStoreTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + @Test + fun `an empty store loads an empty map`() = runTest { + assertTrue(InMemorySessionWatermarkStore().loadAll().isEmpty()) + } + + @Test + fun `replaceAll then loadAll returns the same watermarks`() = runTest { + val store = InMemorySessionWatermarkStore() + + store.replaceAll(mapOf(idA to 10L, idB to 20L)) + + assertEquals(mapOf(idA to 10L, idB to 20L), store.loadAll()) + } + + @Test + fun `replaceAll returns exactly what was stored`() = runTest { + val store = InMemorySessionWatermarkStore() + + val stored = store.replaceAll(mapOf(idA to 10L, "garbage" to 20L)) + + assertEquals(mapOf(idA to 10L), stored) + assertEquals(stored, store.loadAll()) + } + + @Test + fun `replaceAll is a wholesale swap, not a merge`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L)) + + store.replaceAll(mapOf(idB to 20L)) + + assertEquals(mapOf(idB to 20L), store.loadAll()) + } + + @Test + fun `an initial map is sanitized too`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L, "garbage" to 20L)) + + assertEquals(mapOf(idA to 10L), store.loadAll()) + } + + @Test + fun `remove drops one session and returns the new map`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L, idB to 20L)) + + val remaining = store.remove(idA) + + assertEquals(mapOf(idB to 20L), remaining) + assertEquals(remaining, store.loadAll()) + } + + @Test + fun `removing an unknown session is a no-op`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L)) + + assertEquals(mapOf(idA to 10L), store.remove(idB)) + assertEquals(mapOf(idA to 10L), store.loadAll()) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodecTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodecTest.kt new file mode 100644 index 0000000..5c2c366 --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodecTest.kt @@ -0,0 +1,60 @@ +package wang.yaojia.webterm.hostregistry + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * At-rest codec tests (mirrors `HostCodecTest`): encode is total, decode is defensive — + * the blob is untrusted at rest, so anything undecodable reads back as "no watermarks" + * rather than crashing the session list on cold start. + */ +class SessionWatermarkCodecTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + @Test + fun `round-trips a watermark map`() { + val map = mapOf(idA to 1_700_000_000_000L, idB to 42L) + + assertEquals(map, SessionWatermarkCodec.decode(SessionWatermarkCodec.encode(map))) + } + + @Test + fun `an empty map round-trips to an empty map`() { + assertTrue(SessionWatermarkCodec.decode(SessionWatermarkCodec.encode(emptyMap())).isEmpty()) + } + + @Test + fun `null reads back as empty`() { + assertTrue(SessionWatermarkCodec.decode(null).isEmpty()) + } + + @Test + fun `blank reads back as empty`() { + assertTrue(SessionWatermarkCodec.decode(" ").isEmpty()) + } + + @Test + fun `a corrupt blob reads back as empty instead of throwing`() { + assertTrue(SessionWatermarkCodec.decode("{not json").isEmpty()) + } + + @Test + fun `a wrong-shaped blob reads back as empty`() { + // A JSON array where a map was written (e.g. a hand-edited or superseded format). + assertTrue(SessionWatermarkCodec.decode("""["$idA"]""").isEmpty()) + } + + @Test + fun `a non-numeric instant reads back as empty`() { + assertTrue(SessionWatermarkCodec.decode("""{"$idA":"yesterday"}""").isEmpty()) + } + + @Test + fun `decode does not itself validate ids - the store transforms do`() { + // Keeping the codec dumb keeps ONE place (sanitizedWatermarks) responsible for trust. + assertEquals(mapOf("garbage" to 5L), SessionWatermarkCodec.decode("""{"garbage":5}""")) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkTransformsTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkTransformsTest.kt new file mode 100644 index 0000000..d54e310 --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkTransformsTest.kt @@ -0,0 +1,150 @@ +package wang.yaojia.webterm.hostregistry + +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 + +/** + * Pure-transform tests for the watermark map shared by [InMemorySessionWatermarkStore] and + * [DataStoreSessionWatermarkStore] (mirrors `HostStoreTransformsTest` / + * `AuthCookieStoreTransformsTest`). These are the JVM-testable half of the storage split + * (plan §3: "the logic half of :host-registry" is in the Kover gate). + * + * Covers the three at-rest defences and the GC bound: + * - every stored session id is re-validated with the frozen `Validation.isValidSessionId`; + * - non-positive instants are dropped (a watermark of 0 means "never seen" anyway); + * - case-variant spellings of one id collapse via max (they are the same session); + * - the map is capped at [MAX_WATERMARKS], newest kept — what bounds growth. + */ +class SessionWatermarkTransformsTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + // ── sanitizedWatermarks() ───────────────────────────────────────────────── + + @Test + fun `a valid map survives sanitizing unchanged`() { + val map = mapOf(idA to 10L, idB to 20L) + + assertEquals(map, map.sanitizedWatermarks()) + } + + @Test + fun `a non-v4 session id is dropped`() { + // UUID v1 (version nibble 1) parses as a UUID but is not a valid wire session id. + val v1 = "3f2504e0-4f89-11d3-9a0c-0305e82c3301" + val map = mapOf(idA to 10L, v1 to 20L, "abc123" to 30L, "" to 40L) + + assertEquals(mapOf(idA to 10L), map.sanitizedWatermarks()) + } + + @Test + fun `non-positive instants are dropped`() { + val map = mapOf(idA to 0L, idB to -5L) + + assertTrue(map.sanitizedWatermarks().isEmpty()) + } + + @Test + fun `case-variant spellings of one id collapse to the newest`() { + val map = mapOf(idA to 10L, idA.uppercase() to 99L) + + assertEquals(mapOf(idA to 99L), map.sanitizedWatermarks()) + } + + @Test + fun `sanitizing preserves encounter order`() { + val map = linkedMapOf(idB to 20L, idA to 10L) + + assertEquals(listOf(idB, idA), map.sanitizedWatermarks().keys.toList()) + } + + @Test + fun `sanitizing never mutates the receiver`() { + val map = linkedMapOf(idA to 10L, "abc123" to 20L) + + map.sanitizedWatermarks() + + assertEquals(linkedMapOf(idA to 10L, "abc123" to 20L), map) + } + + // ── cappedWatermarks() ─────────────────────────────────────────────────── + + @Test + fun `a map at the cap is returned unchanged`() { + val map = watermarks(MAX_WATERMARKS) + + assertEquals(map, map.cappedWatermarks()) + } + + @Test + fun `over the cap the newest entries are kept`() { + val map = watermarks(MAX_WATERMARKS + 10) + + val capped = map.cappedWatermarks() + + assertEquals(MAX_WATERMARKS, capped.size) + // Instants ascend with the index, so the 10 oldest (lowest) must be the dropped ones. + assertFalse(capped.containsKey(sessionId(0))) + assertTrue(capped.containsKey(sessionId(MAX_WATERMARKS + 9))) + } + + @Test + fun `equal instants break the tie deterministically by session id`() { + val map = (0 until MAX_WATERMARKS + 2).associate { sessionId(it) to 7L } + + val capped = map.cappedWatermarks() + + assertEquals(MAX_WATERMARKS, capped.size) + assertEquals(map.cappedWatermarks().keys, capped.keys) // stable across runs + // Lowest ids win the tie-break, so the two highest ids are the dropped ones. + assertFalse(capped.containsKey(sessionId(MAX_WATERMARKS + 1))) + } + + // ── removingWatermark() ────────────────────────────────────────────────── + + @Test + fun `removing drops just that session`() { + val map = mapOf(idA to 10L, idB to 20L) + + assertEquals(mapOf(idB to 20L), map.removingWatermark(idA)) + } + + @Test + fun `removing an unknown session is a no-op`() { + val map = mapOf(idA to 10L) + + assertEquals(map, map.removingWatermark(idB)) + } + + @Test + fun `removing is case-insensitive like the id itself`() { + val map = mapOf(idA to 10L) + + assertTrue(map.removingWatermark(idA.uppercase()).isEmpty()) + } + + // ── forStorage() = sanitize + cap ──────────────────────────────────────── + + @Test + fun `forStorage sanitizes and caps in one pass`() { + val map = watermarks(MAX_WATERMARKS + 1) + mapOf("garbage" to 999_999L) + + val stored = map.forStorage() + + assertEquals(MAX_WATERMARKS, stored.size) + assertFalse(stored.containsKey("garbage")) + } + + // ── helpers ───────────────────────────────────────────────────────────── + + /** A distinct valid v4 id per [index] (the last 4 hex digits carry the index). */ + private fun sessionId(index: Int): String = + "3f2504e0-4f89-41d3-9a0c-0305e82c%04x".format(index) + + /** [count] valid watermarks whose instants ascend with the index (1-based, so all positive). */ + private fun watermarks(count: Int): Map = + (0 until count).associate { sessionId(it) to (it + 1).toLong() } +} diff --git a/android/keystore.properties.example b/android/keystore.properties.example new file mode 100644 index 0000000..abcb3ec --- /dev/null +++ b/android/keystore.properties.example @@ -0,0 +1,31 @@ +# TEMPLATE ONLY — copy to `keystore.properties` and fill in. NEVER commit the copy. +# +# cp keystore.properties.example keystore.properties +# +# BEFORE you create the real file, make sure `keystore.properties` is listed in +# android/.gitignore. If it is not there yet, use `local.properties` instead — that +# path is ALREADY gitignored and :app reads the same four keys from it. +# +# Resolution order in app/build.gradle.kts (first non-blank wins): +# 1. android/keystore.properties +# 2. android/local.properties +# 3. WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI) +# +# With none of them set, debug + benchmark builds work normally and +# `:app:assembleRelease` FAILS at packaging with instructions — it never emits an +# unsigned APK. +# +# Generate a keystore OUTSIDE the repository: +# keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \ +# -alias webterm -keyalg RSA -keysize 4096 -validity 10000 +# +# Then read its SHA-256 (needed for the App Links assetlinks.json — plan §8): +# keytool -list -v -keystore ~/.webterm/webterm-release.jks -alias webterm | grep SHA256 + +# Path to the keystore. Absolute, or relative to the `android/` directory. +# Keep the keystore itself out of the repo (android/.gitignore blocks *.jks / *.keystore, +# but out-of-tree is the safer habit — a lost signing key cannot be recovered). +webterm.release.storeFile=/absolute/path/to/webterm-release.jks +webterm.release.storePassword=CHANGE_ME +webterm.release.keyAlias=webterm +webterm.release.keyPassword=CHANGE_ME diff --git a/android/macrobenchmark/build.gradle.kts b/android/macrobenchmark/build.gradle.kts new file mode 100644 index 0000000..7d0d732 --- /dev/null +++ b/android/macrobenchmark/build.gradle.kts @@ -0,0 +1,89 @@ +// ───────────────────────────────────────────────────────────────────────────── +// :macrobenchmark (A35) — the macrobenchmark harness module. +// +// `com.android.test`, NOT `com.android.library`: macrobenchmark must drive the app +// OUT of process (its own APK, its own process, UiAutomator across the boundary) so +// that measured startup/frame timings are the real ones and not perturbed by the +// test framework sharing the app's process. +// +// It instruments :app's `benchmark` variant — non-debuggable, R8'd and +// resource-shrunk like release, but signed with the debug key so no release keystore +// is needed (see app/build.gradle.kts → buildTypes.benchmark). +// +// This module currently has NO sources: the benchmark itself (pair → attach → type → +// approve, plus a cold-start timing) is the next task's deliverable and belongs in +// src/main/kotlin/. The module exists so that work is a matter of adding a file. +// +// Run (device/emulator required — `./gradlew :app:assembleDebug` style CI cannot): +// ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest +// +// AGP 9 has built-in Kotlin → apply ONLY the android plugin (no kotlin.android). +// The plugin id is applied BARE (no catalog alias / no version): the root build script +// already puts AGP on the classpath via the android.library + android.application +// aliases, and Gradle rejects a third versioned request for the same plugin artifact +// ("already on the classpath with an unknown version"). The version is still pinned in +// one place — `agp` in gradle/libs.versions.toml. Same pattern as :terminal-view. +// ───────────────────────────────────────────────────────────────────────────── + +plugins { + id("com.android.test") +} + +android { + namespace = "wang.yaojia.webterm.macrobenchmark" + compileSdk = 36 + + defaultConfig { + // Macrobenchmark itself needs API 23+; matching :app's 29 keeps the variant + // matrix honest (a benchmark on an SDK :app cannot run on is meaningless). + minSdk = 29 + targetSdk = 35 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + // The only variant this module is built in. `isDebuggable = true` applies to the + // TEST apk (never to :app) — required so the harness can be instrumented at all. + create("benchmark") { + isDebuggable = true + signingConfig = getByName("debug").signingConfig + // :app publishes debug/release/benchmark; libraries publish debug/release. + matchingFallbacks += listOf("release") + } + } + + targetProjectPath = ":app" + + // The harness runs in its OWN process and reaches the app only through UiAutomator / + // shell, never by linking against its classes. Without this, AGP's + // `checkTestedAppObfuscationBenchmark` refuses to build a non-minified test module + // against a minified app — a check that is correct for Espresso-style tests (which do + // link app symbols) and a false positive for macrobenchmark. + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +kotlin { + jvmToolchain(17) +} + +// Only the `benchmark` variant is meaningful; disable the stock debug/release ones so +// `./gradlew build` does not try to assemble a harness against a variant of :app that +// does not exist. +androidComponents { + beforeVariants { variant -> + variant.enable = variant.buildType == "benchmark" + } +} + +dependencies { + // A `com.android.test` module's `main` source set IS its test code — deps go in + // implementation, not androidTestImplementation. + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.uiautomator) + implementation(libs.androidx.benchmark.macro.junit4) +} diff --git a/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/PairAttachTypeApproveBenchmark.kt b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/PairAttachTypeApproveBenchmark.kt new file mode 100644 index 0000000..7f6a494 --- /dev/null +++ b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/PairAttachTypeApproveBenchmark.kt @@ -0,0 +1,156 @@ +package wang.yaojia.webterm.macrobenchmark + +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * **A35 — the one happy-path journey: pair → attach → type → approve.** + * + * The whole product in a single measured trace, driven out of process against `:app`'s minified `benchmark` + * variant. [FrameTimingMetric] is the point: the terminal is a text renderer fed by a socket, so what a user + * feels is frame duration while output streams and while the key-bar is tapped — not the wall clock of any + * single step. [StartupTimingMetric] rides along so a regression can be attributed to launch versus journey. + * + * ### It needs a real host, and it SKIPS when there isn't one + * Pair, attach, type and approve are all *server* interactions. Faking them would measure a mock, so + * [WebTermTarget.requireHost] raises a JUnit assumption failure — reported as skipped, never as a pass — + * when `-e webtermHost` is absent. A benchmark that "passed" while stuck on the pairing screen would publish + * an impressively fast, entirely meaningless number, which is worse than no number. + * + * ### It also needs a gate to approve, which cannot be manufactured from here + * The approve step is only reachable when the session is actually holding a permission gate (Claude Code + * asking for a tool). This benchmark therefore *looks* for the approve affordance and, when it is absent, + * completes the journey without it and says so — rather than typing something that might approve a real + * action on a real machine. The full gate leg belongs to a run against a live Claude session, and stays on + * the device-QA checklist under A35. + * + * Run: + * ``` + * ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.webtermHost=http://10.0.2.2:3000 + * ``` + */ +@RunWith(AndroidJUnit4::class) +class PairAttachTypeApproveBenchmark { + + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun pairAttachTypeApprove() { + val host = WebTermTarget.requireHost() + + benchmarkRule.measureRepeated( + packageName = WebTermTarget.PACKAGE_NAME, + metrics = listOf(StartupTimingMetric(), FrameTimingMetric()), + iterations = WebTermTarget.JOURNEY_ITERATIONS, + // COLD each iteration: pairing state persists in DataStore, so a warm run would skip the pairing + // leg entirely and silently measure a different (shorter) journey. + startupMode = StartupMode.COLD, + setupBlock = { pressHome() }, + ) { + startActivityAndWait() + WebTermTarget.awaitFirstContent(device) + + pairIfNeeded(device, host) + openASession(device) + typeIntoTheTerminal(device) + approveIfAGateIsHeld(device) + } + } + + /** + * The pairing leg. On a device that is already paired the app cold-starts past this screen (A29's + * `ColdStartPolicy`), so the whole leg is conditional — which is also why the journey runs COLD. + */ + private fun pairIfNeeded(device: UiDevice, host: String) { + val onPairing = device.wait( + Until.hasObject(By.textContains(WebTermTarget.PAIRING_TITLE)), + WebTermTarget.UI_TIMEOUT_MS, + ) == true + if (!onPairing) return + + // The segmented control defaults to manual entry, but selecting it explicitly keeps the journey + // deterministic if that default ever changes. + device.findObject(By.textContains(WebTermTarget.MANUAL_ENTRY))?.click() + + val field = device.wait( + Until.findObject(By.textContains(WebTermTarget.HOST_FIELD_HINT)), + WebTermTarget.UI_TIMEOUT_MS, + ) ?: device.findObject(By.clazz("android.widget.EditText")) + checkNotNull(field) { "the pairing screen showed no host-address field" } + field.click() + field.text = host + + val next = device.wait( + Until.findObject(By.textContains(WebTermTarget.NEXT).enabled(true)), + WebTermTarget.UI_TIMEOUT_MS, + ) + checkNotNull(next) { "the 下一步 button never became enabled for $host — the probe failed" } + next.click() + } + + /** Open a session: the continue-last affordance if there is one, else the first row in the list. */ + private fun openASession(device: UiDevice) { + val continueLast = device.wait( + Until.findObject(By.textContains(WebTermTarget.CONTINUE_LAST)), + WebTermTarget.UI_TIMEOUT_MS, + ) + if (continueLast != null) { + continueLast.click() + } else { + // Any row works; the journey is about attach + render, not about which session. + val row = device.wait( + Until.findObject(By.pkg(WebTermTarget.PACKAGE_NAME).clickable(true)), + WebTermTarget.UI_TIMEOUT_MS, + ) + checkNotNull(row) { "no session was openable — is the host running any sessions?" } + row.click() + } + // Attach + ring-buffer replay: the frames this produces are the ones FrameTimingMetric is here for. + device.waitForIdle(WebTermTarget.ATTACH_TIMEOUT_MS) + } + + /** + * Type through the key-bar, not the IME. That is the real mobile input path (the bar bypasses the IME + * precisely so the keyboard never pops), and it avoids making the measurement depend on which IME the + * device happens to ship. + */ + private fun typeIntoTheTerminal(device: UiDevice) { + val enter = device.wait(Until.findObject(By.desc(ENTER_KEY_DESCRIPTION)), WebTermTarget.UI_TIMEOUT_MS) + if (enter != null) { + enter.click() + device.waitForIdle() + return + } + // Wide screens hide the key-bar (web parity, >768dp) — fall back to the terminal surface itself. + device.click(device.displayWidth / 2, device.displayHeight / 2) + device.waitForIdle() + } + + /** + * Approve — only if a gate is genuinely held. Absent a gate the affordance is not on screen and this is + * a no-op: the alternative (synthesising a tap where the button would be) risks approving a real tool + * call on a real machine, which a benchmark has no business doing. + */ + private fun approveIfAGateIsHeld(device: UiDevice) { + device.findObject(By.text(WebTermTarget.APPROVE))?.let { approve -> + approve.click() + device.waitForIdle() + } + } + + private companion object { + /** `KEYBAR_LAYOUT`'s accessibility label for the Enter key. */ + const val ENTER_KEY_DESCRIPTION = "Enter — confirm" + } +} diff --git a/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/StartupBenchmark.kt b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/StartupBenchmark.kt new file mode 100644 index 0000000..5b2d50c --- /dev/null +++ b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/StartupBenchmark.kt @@ -0,0 +1,67 @@ +package wang.yaojia.webterm.macrobenchmark + +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * **A35 — cold- and warm-start timing for the `benchmark` variant of `:app`.** + * + * Measured out of process by UiAutomator against a non-debuggable, R8'd, resource-shrunk APK, because those + * are the only numbers that mean anything: a debug build's timings are dominated by the absence of R8 and by + * debuggable-mode JIT policy, and an in-process test perturbs the thing it measures. + * + * ### What counts as a result + * Real hardware only. The `webterm` AVD is a **software-GPU emulator**: its startup figures (≈1.1 s cold, + * recorded in `DEVICE_QA_CHECKLIST.md`) are proof that the app starts cleanly under R8, and are NOT a + * baseline. Quoting an emulator number as a performance baseline is the specific mistake this comment + * exists to prevent. Macrobenchmark itself agrees: it refuses to report on an emulator unless + * `androidx.benchmark.suppressErrors` is set, so a run there is explicitly a smoke test. + * + * Run: + * ``` + * ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest + * ``` + */ +@RunWith(AndroidJUnit4::class) +class StartupBenchmark { + + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + /** + * COLD start — process creation included. This is the number that matters for the product's core use + * case: picking the phone up hours later to check on a walked-away Claude session. + */ + @Test + fun coldStartup() = measureStartup(StartupMode.COLD) + + /** + * WARM start — the process survived, the Activity did not. Kept because it isolates Activity/Compose + * setup from process creation, so a regression can be attributed rather than merely noticed. + */ + @Test + fun warmStartup() = measureStartup(StartupMode.WARM) + + private fun measureStartup(mode: StartupMode) = benchmarkRule.measureRepeated( + packageName = WebTermTarget.PACKAGE_NAME, + metrics = listOf(StartupTimingMetric()), + iterations = WebTermTarget.STARTUP_ITERATIONS, + startupMode = mode, + setupBlock = { + // COLD needs the process gone; pressHome also clears any leftover Activity from a prior + // iteration so warm runs measure Activity creation rather than a resume. + pressHome() + }, + ) { + startActivityAndWait() + // Waiting for the first real frame is what makes the measurement about the app being USABLE rather + // than about the window animation. Whichever route cold start lands on (pairing screen with no host, + // session list with one), something from the app's own content must be on screen. + WebTermTarget.awaitFirstContent(device) + } +} diff --git a/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/WebTermTarget.kt b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/WebTermTarget.kt new file mode 100644 index 0000000..0838c33 --- /dev/null +++ b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/WebTermTarget.kt @@ -0,0 +1,88 @@ +package wang.yaojia.webterm.macrobenchmark + +import android.os.Bundle +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.Assume + +/** + * What the out-of-process harness knows about `:app` — deliberately almost nothing. + * + * `:macrobenchmark` is a separate APK driving a separate process, so it cannot (and must not) link against + * `:app`'s classes: that is what keeps the measured process the shipping one. Everything it needs is + * therefore either a package name, a piece of on-screen text, or a run-time argument. + * + * The UI strings below are duplicated from `:app`'s Compose sources rather than shared, which is a real cost + * (a copy-rename will not fail the compile, it will fail the benchmark at run time with "no node matched"). + * The alternative — a `project(":app")` dependency — would break the out-of-process property that makes the + * numbers valid, so the duplication is the right trade. It is kept small on purpose. + */ +internal object WebTermTarget { + + /** `:app`'s `applicationId`. The debug and benchmark variants share it (no suffix), by design (A32). */ + const val PACKAGE_NAME: String = "wang.yaojia.webterm" + + const val STARTUP_ITERATIONS: Int = 5 + const val JOURNEY_ITERATIONS: Int = 3 + + /** How long to wait for a Compose surface to appear before calling it a failure. */ + const val UI_TIMEOUT_MS: Long = 10_000L + + /** Long enough for a real attach + ring-buffer replay over a LAN. */ + const val ATTACH_TIMEOUT_MS: Long = 20_000L + + // ── On-screen text (mirrors :app; see the class doc for why it is duplicated) ──────────────── + const val PAIRING_TITLE: String = "配对主机" + const val HOST_FIELD_HINT: String = "主机地址(http(s)://…)" + const val NEXT: String = "下一步" + const val MANUAL_ENTRY: String = "手动输入" + const val APPROVE: String = "批准" + const val CONTINUE_LAST: String = "继续上次会话" + + /** Instrumentation argument naming a reachable WebTerm host, mirroring the A34 suite's own. */ + const val ARG_HOST: String = "webtermHost" + + private val arguments: Bundle get() = InstrumentationRegistry.getArguments() + + fun argument(name: String): String? = arguments.getString(name)?.takeIf { it.isNotBlank() } + + /** + * Wait until the app has painted something of its OWN — not just a window. Cold start with no paired + * host lands on the pairing screen; with a host it lands on the session list, which shows the + * continue-last affordance. Accepting either keeps the startup measurement valid on a device in any + * state, while still failing loudly on a blank or crashed first frame. + */ + fun awaitFirstContent(device: UiDevice) { + val appeared = device.wait( + Until.hasObject(By.textContains(PAIRING_TITLE)), + UI_TIMEOUT_MS, + ) == true || + device.wait(Until.hasObject(By.textContains(CONTINUE_LAST)), UI_TIMEOUT_MS) == true || + device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), UI_TIMEOUT_MS) != null + check(appeared) { "the app produced no first frame within $UI_TIMEOUT_MS ms" } + } + + /** + * Skip — never silently pass — when the journey benchmark has no host to talk to. A pair → attach → + * type → approve journey is meaningless against nothing, and a benchmark that "succeeded" by never + * leaving the pairing screen would publish a fast, wrong number. + */ + fun requireHost(): String { + val host = argument(ARG_HOST) + Assume.assumeTrue( + """ + |SKIPPED — the pair → attach → type → approve journey needs a real WebTerm host, and none was + |supplied. Start this repo's server (allowing the origin the device will send) and pass its + |address: + | ALLOWED_ORIGINS=http://:3000 npm start + | ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \ + | -Pandroid.testInstrumentationRunnerArguments.$ARG_HOST=http://:3000 + |On an emulator, is 10.0.2.2 (the platform alias for the host's loopback). + """.trimMargin(), + host != null, + ) + return host!! + } +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt index bab53df..5243d0b 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt @@ -1,5 +1,6 @@ package wang.yaojia.webterm.session +import wang.yaojia.webterm.wire.ApprovalPreview import wang.yaojia.webterm.wire.ApproveMode import wang.yaojia.webterm.wire.ClientMessage import wang.yaojia.webterm.wire.GateKind @@ -22,6 +23,15 @@ public data class GateState( val detail: String?, /** Rising-edge counter; see type doc. */ val epoch: Int, + /** + * W1 — the command or diff this gate is asking about, so the user is not approving blind. + * + * `null` is a NORMAL state, not an error: the server only sends a preview for a held Bash/Edit-family + * tool, and a malformed one decodes to null rather than costing us the gate. UI must therefore render + * a previewless gate as an ordinary gate, never as a failure. Every string inside is UNTRUSTED and + * must render inert (plan §8). + */ + val preview: ApprovalPreview? = null, ) { /** The action set for this gate: plan → three-way, tool → two-way. */ public val affordances: List diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt index 277ba59..6e6b78b 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt @@ -1,5 +1,7 @@ package wang.yaojia.webterm.session +import wang.yaojia.webterm.wire.ApprovalPreview + import wang.yaojia.webterm.wire.GateKind /** @@ -29,7 +31,12 @@ public class GateTracker private constructor( private val lastEpoch: Int, ) { /** Feeds one `status` frame's gate-relevant fields; returns the next snapshot. */ - public fun reduce(pending: Boolean, gate: GateKind?, detail: String?): GateTracker { + public fun reduce( + pending: Boolean, + gate: GateKind?, + detail: String?, + preview: ApprovalPreview? = null, + ): GateTracker { if (!pending) { // Falling edge (or still idle): gate lifted, epoch counter retained. return GateTracker(current = null, lastEpoch = lastEpoch) @@ -38,12 +45,14 @@ public class GateTracker private constructor( val held = current if (held != null) { // Sustained pending: same epoch, refresh kind/detail from the frame. - val refreshed = GateState(kind = kind, detail = detail, epoch = held.epoch) + // Refresh the preview too: the server may send the held status again with a preview it did + // not have on the rising edge (the bounded render can arrive a frame late). + val refreshed = GateState(kind = kind, detail = detail, epoch = held.epoch, preview = preview) return GateTracker(current = refreshed, lastEpoch = lastEpoch) } // Rising edge: mint the next epoch (the ONLY place it increments). val nextEpoch = lastEpoch + 1 - val risen = GateState(kind = kind, detail = detail, epoch = nextEpoch) + val risen = GateState(kind = kind, detail = detail, epoch = nextEpoch, preview = preview) return GateTracker(current = risen, lastEpoch = nextEpoch) } diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt index 4857a73..ee68997 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt @@ -281,6 +281,7 @@ public class SessionEngine( is ServerMessage.Output -> { emit(Output(msg.data)); null } is ServerMessage.Status -> { onStatus(msg); null } is ServerMessage.Telemetry -> { emit(Telemetry(msg.telemetry)); null } + is ServerMessage.Queue -> { emit(Queued(msg.length)); null } is ServerMessage.Exit -> { emit(Exited(msg.code, msg.reason)) EndCause.Terminal @@ -299,7 +300,7 @@ public class SessionEngine( private fun onStatus(status: ServerMessage.Status) { val previous = gateTracker.current - gateTracker = gateTracker.reduce(status.pending, status.gate, status.detail) + gateTracker = gateTracker.reduce(status.pending, status.gate, status.detail, status.preview) val next = gateTracker.current if (next != previous) emit(Gate(next)) } diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt index d2d465d..f228032 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt @@ -27,3 +27,13 @@ public data class Digest(val digest: AwayDigest) : SessionEvent /** Latest statusLine telemetry broadcast (mirrors the server `telemetry` frame, B2). */ public data class Telemetry(val telemetry: StatusTelemetry) : SessionEvent + +/** + * W2 — pending-inject queue depth for this session, broadcast to every attached device so all mirrors + * agree on the "N queued" badge. + * + * The server has always sent this frame; Android had no `ServerMessageType` member for it, so + * `decodeServer` returned null and `SessionEngine` discarded it as undecodable. [length] is + * non-negative — the codec drops a negative depth rather than passing nonsense to a badge. + */ +public data class Queued(val length: Int) : SessionEvent diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineQueueAndPreviewTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineQueueAndPreviewTest.kt new file mode 100644 index 0000000..a52b8a7 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineQueueAndPreviewTest.kt @@ -0,0 +1,168 @@ +package wang.yaojia.webterm.session + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeTermTransport +import wang.yaojia.webterm.wire.ApprovalPreview +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.TermTransport + +/** + * Engine-level proof for the two frames Android used to lose (W1 `status.preview`, W2 `queue`). + * + * `MessageCodec` decoding them is necessary but not sufficient: the reason they were invisible is that + * `SessionEngine.onFrame` is where a decoded frame becomes a `SessionEvent`, and a frame with no branch + * there is discarded just as silently as one that fails to decode. These tests drive the real engine + * through the fake transport so a future refactor that drops the branch fails here. + */ +@DisplayName("SessionEngine — queue depth and approval preview reach the UI") +class SessionEngineQueueAndPreviewTest { + + private fun endpoint(): HostEndpoint = HostEndpoint.fromBaseUrl("http://localhost:3000")!! + + private fun TestScope.newEngine(transport: TermTransport): SessionEngine = SessionEngine( + transport = transport, + endpoint = endpoint(), + scope = backgroundScope, + nowMs = { 0L }, + ) + + private fun TestScope.collectEvents(engine: SessionEngine): List { + val seen = mutableListOf() + backgroundScope.launch { engine.events.collect { seen += it } } + return seen + } + + private fun TestScope.started(transport: FakeTermTransport): Pair> { + val engine = newEngine(transport) + val events = collectEvents(engine) + engine.start() + testScheduler.runCurrent() + return engine to events + } + + @Test + @DisplayName("a queue frame becomes a Queued event instead of being dropped") + fun queueFrameSurfaces() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + transport.emit("""{"type":"queue","length":4}""") + testScheduler.runCurrent() + + assertEquals( + listOf(4), + events.filterIsInstance().map { it.length }, + "before this fix `queue` had no ServerMessageType member, so decodeServer returned null " + + "and onFrame discarded it as undecodable — the badge could never appear", + ) + } + + @Test + @DisplayName("a malformed queue frame is dropped without disturbing the stream") + fun malformedQueueIsInert() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + transport.emit("""{"type":"queue","length":-3}""") + transport.emit("""{"type":"queue"}""") + transport.emit("""{"type":"queue","length":1}""") + testScheduler.runCurrent() + + assertEquals(listOf(1), events.filterIsInstance().map { it.length }) + } + + @Test + @DisplayName("a held gate carries the command preview, so the user is not approving blind") + fun gateCarriesCommandPreview() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + transport.emit( + """{"type":"status","status":"waiting","pending":true,"gate":"tool","detail":"Bash", + "preview":{"kind":"command","text":"git push --force","truncated":false}}""", + ) + testScheduler.runCurrent() + + val gate = events.filterIsInstance().last().gate + assertNotNull(gate, "the gate itself must still be emitted") + assertEquals( + ApprovalPreview.Command("git push --force", truncated = false), + gate!!.preview, + "the whole point of W1: show WHAT is being approved, not just the tool name", + ) + } + + @Test + @DisplayName("a preview that arrives a frame late is picked up on the sustained status") + fun previewCanArriveLate() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + // Rising edge with no preview yet — the server's bounded render is not always ready in time. + transport.emit("""{"type":"status","status":"waiting","pending":true,"gate":"tool"}""") + testScheduler.runCurrent() + val risen = events.filterIsInstance().last().gate!! + assertNull(risen.preview) + + transport.emit( + """{"type":"status","status":"waiting","pending":true,"gate":"tool", + "preview":{"kind":"command","text":"ls -la"}}""", + ) + testScheduler.runCurrent() + + val refreshed = events.filterIsInstance().last().gate!! + assertEquals(ApprovalPreview.Command("ls -la"), refreshed.preview) + assertEquals( + risen.epoch, + refreshed.epoch, + "a sustained pending must NOT mint a new epoch — a decision tapped before the preview " + + "landed has to stay valid, or the preview arriving would silently invalidate the tap", + ) + } + + @Test + @DisplayName("a broken preview still yields the gate") + fun brokenPreviewStillGates() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + transport.emit( + """{"type":"status","status":"waiting","pending":true,"gate":"tool","preview":{"kind":"nope"}}""", + ) + testScheduler.runCurrent() + + val gate = events.filterIsInstance().lastOrNull()?.gate + assertNotNull(gate, "losing the gate because its preview was malformed would be the worse bug") + assertNull(gate!!.preview) + } + + @Test + @DisplayName("lifting the gate clears the preview with it") + fun liftingClearsPreview() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + transport.emit( + """{"type":"status","status":"waiting","pending":true,"gate":"tool", + "preview":{"kind":"command","text":"rm -rf /"}}""", + ) + testScheduler.runCurrent() + transport.emit("""{"type":"status","status":"working","pending":false}""") + testScheduler.runCurrent() + + assertNull( + events.filterIsInstance().last().gate, + "a resolved gate must not leave a stale command on screen", + ) + assertTrue(events.filterIsInstance().size >= 2) + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 46ba5ca..e7b3182 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -51,3 +51,10 @@ include(":app") // A13 — libs.plugins.android.application + c include(":terminal-view") // A16 — android-library + Termux terminal-emulator/-view (JitPack, Apache-2.0) include(":host-registry") // A12 — android-library + DataStore (Host list / last-session) include(":client-tls-android") // A11 — android-library + AndroidKeyStore import + Tink AEAD + +// ── Instrumented-only module (no production code, never on :app's classpath) ───── +// :macrobenchmark (A35) — a `com.android.test` module that drives :app's `benchmark` +// variant OUT of process via UiAutomator. Separate APK by construction, so it cannot +// violate the "dependencies only flow down" rule: nothing depends on it, and it depends +// on :app only through `targetProjectPath`, not a project() dependency. +include(":macrobenchmark") // A35 — libs.plugins.android.test + benchmark-macro-junit4 diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt new file mode 100644 index 0000000..3c7f780 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt @@ -0,0 +1,585 @@ +package wang.yaojia.webterm.terminalview + +import android.content.Context +import android.graphics.Canvas +import android.util.TypedValue +import android.view.ActionMode +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.ViewConfiguration +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputMethodManager +import android.widget.FrameLayout +import com.termux.terminal.TerminalEmulator +import com.termux.view.TerminalView +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * The `View` that actually goes into the Compose tree: a frame around the stock Termux [TerminalView], + * which keeps doing all the glyph rendering — and which this frame must also keep away from the three + * stock paths that dereference the `TerminalSession` this app deliberately does not have. + * + * ### Why a frame and not the stock view directly (plan §6.1 deviation, recorded) + * `com.termux.view.TerminalView` is `public final` — it cannot be subclassed — and its + * `onCreateInputConnection` (offsets 52-62) hard-wires the anonymous `TerminalView$2` connection whose + * text path dead-ends on the absent `TerminalSession`. `onCreateInputConnection` is called on the + * **focused** view and there is no interception hook, so owning the IME contract means owning a focusable + * view. This frame is that view: it takes focus, returns [RemoteTerminalInputConnection], and forwards + * hardware keys down into the stock view so the [WebTermTerminalViewClient] decision path still runs. + * + * ### What the frame must intercept, and why (all offsets from the pinned `terminal-view-v0.118.0.aar`) + * `mTermSession` is **null forever** here: `TerminalSession` is `final`, its constructor forks a local + * process over JNI, and a dummy instance would only move the NPE (`getEmulator()` returns null two + * instructions later). Every stock method that dereferences it must therefore be made unreachable: + * + * 1. **scroll** — `doScroll` offsets 56-79 call `handleKeyCode(19|20, 0)` while the alternate screen + * buffer is active, and `handleKeyCode` offsets 15-19 are `mTermSession.getEmulator()`, unguarded. + * Reached from `TerminalView$1.onScroll`, the `TerminalView$1$1` fling runnable and + * `onGenericMotionEvent`; no `TerminalViewClient` callback sits anywhere on that path. + * → [onInterceptTouchEvent] / [dispatchGenericMotionEvent] claim it; [TerminalScrollGesture] does it. + * 2. **autofill** — `autofill(AutofillValue)` offsets 7-20 are `mTermSession.write(...)`, and the view + * advertises itself as fillable (`getAutofillType()` = AUTOFILL_TYPE_TEXT, `getAutofillValue()` + * non-null), so any password manager filling it crashes the app. → the whole subtree is excluded from + * the autofill structure ([getImportantForAutofill]). + * 3. **keys** — `onKeyDown` offset 150 is `mTermSession.write(getCharacters())`. + * → [WebTermTerminalViewClient] consumes every non-system key before that, and answers false to + * `shouldBackButtonBeMappedToEscape` so BACK cannot enter the terminal branch either. + * 4. **text selection** — `startTextSelectionMode` arms an `ActionMode` whose Copy + * (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100) is + * `mTermSession.onCopyTextToClipboard(...)` and whose Paste (offsets 126-136) is + * `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true so + * the mode can never arm, and the press starts the **first-party** selection layer instead + * ([TerminalSelection] for the design; [beginSelectionAt] / [copySelection] here). Only the stock + * controller is replaced — the affordance is still an Android `ActionMode` over `ClipboardManager`, as + * plan §6.5 specifies. + * + * ### Stock-method audit (verified with `javap -p -c`; keep it current when the pinned version moves) + * | stock member | reachable here? | verdict | + * |---|---|---| + * | `onKeyDown` / `onKeyUp` / `onKeyPreIme` | yes | safe — a client is installed and consumes first | + * | `onCreateInputConnection` | no (frame owns focus) | client installed anyway | + * | `onCheckIsTextEditor`, `isOpaque`, `setTextSize`, `setTypeface` | yes | no session deref | + * | `onDraw`, `onScreenUpdated`, `computeVerticalScroll*` | yes | `mEmulator`/`mRenderer` guarded or built in [init] | + * | `getColumnAndRow`, `getCursorX/Y`, `getPointX/Y`, `getTopRow`, `setTopRow` | yes | no session deref | + * | `onTouchEvent` (mouse buttons: context menu, middle-click paste) | yes | `mEmulator`-guarded, no session deref | + * | `doScroll`, `onGenericMotionEvent`, `TerminalView$1.onScroll`, `TerminalView$1$1.run` | **no** | intercepted here (1) | + * | `handleKeyCode` | **no** | only `doScroll` called it | + * | `autofill`, `getAutofillType`, `getAutofillValue` | **no** | subtree excluded (2) | + * | `inputCodePoint` | no | its own `mTermSession == null` early return (offsets 59-66) | + * | `updateSize` | no | early-returns without a session; [TerminalResizeDriver] replaces it | + * | `attachSession` | no | never called — it is the process-forking path we replace | + * | `startTextSelectionMode` / `TextSelectionCursorController` | **no** | long press consumed (4) | + * | `mDefaultSelectors` (the renderer's own highlight channel) | no (package-private) | our overlay draws it | + * | `setTerminalCursorBlinker*` | never invoked | cosmetic gap vs stock: the cursor does not blink | + * + * ### The latent renderer crash this also fixes + * `TerminalView.mRenderer` is only created by `setTextSize`, and nothing called it. `onDraw` dereferences + * `mRenderer` unconditionally once `mEmulator` is non-null (offsets 36-61), so the very first frame after + * binding our emulator would have thrown. [init] sets the text size, which both creates the renderer and + * is what makes the real cell metrics ([cellMetrics]) available for the resize path. + * + * ### Why `dispatchDraw` is NOT gated (the fourth 2026-07-30 crash, and where it was actually fixed) + * The stock `onDraw` → `TerminalRenderer.render` walk is not defensive: it caches its column bound from + * `TerminalEmulator.mColumns` and then indexes each row's `long[]` style array, which is sized to whatever + * `TerminalBuffer.mColumns` was when that row was allocated. A regrid landing mid-frame therefore killed + * the process with `ArrayIndexOutOfBoundsException: length=31; index=31`, and there is no lock to take — + * `monitorenter` appears nowhere in the pinned emulator or renderer. This frame owns the child's + * `dispatchDraw`, so suppressing the draw during a mutation was one available fix; it was NOT the one + * chosen, because it would have to cover every mutation (appends swap `TerminalRow.mText` too) and would + * mean either blanking the terminal or blocking the UI thread on a 10 000-row reflow. + * + * Instead [RemoteTerminalSession] mutates the emulator **on this thread** — the main thread — exactly as + * upstream Termux does, so a mutation and a draw are two main-thread work items and cannot interleave at + * all. That is the invariant this view depends on: **nothing may mutate `stockView.mEmulator`'s state off + * the main thread.** If that is ever broken, gating here becomes necessary again. + * + * DEVICE QA (plan §7): focus/IME behaviour across IMEs, CJK composition, link taps, scroll feel (slop, + * momentum) and the real font-metric grid are verified on a device — this class is glue, and the logic it + * delegates to ([TerminalKeyDecoder], [TerminalInputBytes], [TerminalResizeDriver], [TerminalScrollGesture]) + * is what the JVM tests pin. + */ +public class RemoteTerminalHostView internal constructor( + context: Context, + clipboard: TerminalClipboard = SystemTerminalClipboard(context), +) : FrameLayout(context) { + + /** The stock Termux view. Rendering is entirely its own; we only feed it an emulator and input. */ + internal val stockView: TerminalView = TerminalView(context, null) + + /** Set once by [RemoteTerminalView]; every input path routes through it. */ + internal var sink: TerminalInputSink? = null + + /** The event currently being dispatched — the mouse-report branch needs its coordinates + down-time. */ + private var eventInFlight: MotionEvent? = null + + /** Stock `mMouseScrollStartX/Y` + `mMouseStartDownTime`: one anchor cell per wheel gesture. */ + private var wheelAnchorDownTime: Long = NO_DOWN_TIME + private var wheelAnchorColumn: Int = 0 + private var wheelAnchorRow: Int = 0 + + private val scrollGesture = TerminalScrollGesture( + touchSlopPx = ViewConfiguration.get(context)?.scaledTouchSlop?.takeIf { it > 0 } + ?: FALLBACK_TOUCH_SLOP_PX, + cellHeightPx = { cellMetrics()?.lineSpacingPx ?: NO_CELL_HEIGHT }, + target = object : TerminalScrollTarget { + override fun emulator(): TerminalEmulator? = stockView.mEmulator + + override fun scrollTranscript(up: Boolean) { + val transcriptRows = stockView.mEmulator?.screen?.activeTranscriptRows ?: return + val step = if (up) -1 else 1 + stockView.topRow = min(NEWEST_ROW, max(-transcriptRows, stockView.topRow + step)) + stockView.invalidate() + // The selection highlight is recorded in THIS view's display list and is positioned + // relative to `topRow`, so invalidating only the child would re-render scrolled glyphs + // under a stale band. Reachable with a selection alive: an external mouse wheel still + // scrolls (a selection lives in external rows, so moving the viewport is harmless). + this@RemoteTerminalHostView.invalidate() + } + + override fun sendKeys(data: String) { + sink?.sendInput(data) + } + + override fun reportMouseWheel(up: Boolean) = sendMouseWheel(up) + }, + ) + + // ── Text selection (the first-party replacement for the stock, crashing one) ────────────────────── + + private val selectionBuffer = TerminalEmulatorSelectionBuffer { stockView.mEmulator } + + private val selection = TerminalSelectionController(selectionBuffer, clipboard) { isActive -> + if (isActive) { + showCopyAffordance() + } else { + // A selection can be dropped mid-drag (a resize, a rebind), so the drag flag has to die with + // it — a stale "true" would make the next pinch look like an extend. + isExtendingSelection = false + hideCopyAffordance() + } + // The highlight is painted by THIS view (dispatchDraw), so the frame — not just the child — has to + // be invalidated for a span change to appear. + invalidate() + onSelectionChanged?.invoke(isActive) + } + + /** Set by [RemoteTerminalView] so `:app` can mirror the state in its own toolbar. */ + internal var onSelectionChanged: ((isActive: Boolean) -> Unit)? = null + + private val selectionPainter = TerminalSelectionPainter() + + /** The live floating toolbar, or null when nothing is selected (or the platform declined to start one). */ + private var copyAffordance: ActionMode? = null + + /** True between the first extend MOVE and the lift, so the UP of a drag we own is reported as ours. */ + private var isExtendingSelection = false + + init { + addView(stockView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + // Builds TerminalRenderer → onDraw is safe and the cell metrics become readable. + stockView.setTextSize(defaultTextSizePx(context)) + // The frame owns focus and therefore the IME; the stock view must not compete for it. + stockView.isFocusable = false + stockView.isFocusableInTouchMode = false + descendantFocusability = FOCUS_BLOCK_DESCENDANTS + isFocusable = true + isFocusableInTouchMode = true + // Belt and braces with getImportantForAutofill(): the setter is what a structure walk normally + // reads, the override is what a later setImportantForAutofill() cannot undo. + importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS + stockView.importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS + } + + /** Install the client on the stock view. Idempotent; called as soon as the sink is known. */ + internal fun installClient(client: com.termux.view.TerminalViewClient) { + stockView.setTerminalViewClient(client) + } + + /** Point the stock renderer at our forked emulator (bypasses the process-forking `attachSession`). */ + internal fun bindEmulator(emulator: TerminalEmulator) { + stockView.mEmulator = emulator + // A new emulator means a new transcript: drop any half-finished drag rather than let it resolve + // rows against a buffer it did not start in, and drop any selection for the same reason — its rows + // name content that no longer exists. + scrollGesture.reset() + selection.clear() + } + + /** + * Invalidate/redraw the stock view for the current emulator state (main thread), keeping a live + * selection pointing at the text it was made on. + * + * `TerminalView.onScreenUpdated` maintains a selection across incoming output — it shifts `mTopRow` by + * `getScrollCounter()` and decrements its selection cursors — but only when `isSelectingText()` is true, + * which for us is permanently false (we never arm the stock controller). What it does instead is reset + * `mTopRow` to 0 and CLEAR the scroll counter, so both halves of that bookkeeping have to happen here: + * read the counter first (nothing else can, afterwards), then re-pin the viewport and shift the span. + * Skipping it would leave the user's highlight over text that scrolled away — and Copy would then put + * something the user never selected on the clipboard. + */ + internal fun requestScreenUpdate() { + val isSelecting = selection.isActive + val scrolledRows = if (isSelecting) stockView.mEmulator?.scrollCounter ?: NO_SCROLL else NO_SCROLL + val pinnedTopRow = if (isSelecting) stockView.topRow - scrolledRows else null + + stockView.onScreenUpdated() + + if (pinnedTopRow != null) { + val oldestRow = -(stockView.mEmulator?.screen?.activeTranscriptRows ?: 0) + stockView.topRow = pinnedTopRow.coerceIn(oldestRow, NEWEST_ROW) + } + selection.onContentScrolled(scrolledRows) + invalidate() + } + + // ── Text-selection API (used by [RemoteTerminalView], which exposes it to `:app`) ────────────────── + + internal val isSelectingText: Boolean get() = selection.isActive + + /** + * Start a selection at these view pixels, snapped to the word under them. Answering false means there + * was no measured grid to resolve the pixels against, so the press is ignored. + */ + internal fun beginSelectionAt(xPx: Float, yPx: Float): Boolean { + val cell = cellAt(xPx, yPx) ?: return false + if (!selection.beginAt(cell)) return false + // `getScrollCounter()` accumulates until something clears it, and the only thing that does is + // `onScreenUpdated()`. A multi-chunk append posts one screen update for several main-thread appends, + // so a long press can land between a scroll and its update — and those rows scrolled BEFORE this + // selection existed. Counting them would shift the brand-new span by rows it never saw (measured: a + // selection two rows of stale counter old landed on the wrong line). Zeroing here makes the shift in + // [requestScreenUpdate] mean exactly "scrolled since the selection was made"; nothing else in the + // pinned artifact reads the counter (stock only reads it inside `isSelectingText()`, always false). + stockView.mEmulator?.clearScrollCounter() + return true + } + + /** THE explicit copy (plan §8): nothing else in this module writes terminal text to the clipboard. */ + internal fun copySelection(): TerminalCopyResult = selection.copy() + + internal fun clearSelection() { + selection.clear() + } + + /** The text currently highlighted — for tests and for `:app` to preview; reads the buffer, mutates nothing. */ + internal fun selectedText(): String = selection.selectedText() + + /** The highlight bands for the current selection, in this frame's pixel space. */ + internal fun highlightRects(): List { + val span = selection.selection?.span ?: return emptyList() + val bounds = selectionBuffer.bounds() ?: return emptyList() + val metrics = cellMetrics() ?: return emptyList() + return TerminalSelectionGeometry.highlightRects(span, bounds, metrics, stockView.topRow) + } + + /** + * A tap: dismiss a live selection, otherwise raise the soft keyboard. + * + * Dismissing takes precedence because a selection makes every drag an extend — the tap is how the user + * gets scrolling back, and it is the platform-standard way to end a selection. + */ + internal fun onTapped() { + if (selection.isActive) { + selection.clear() + return + } + showSoftKeyboard() + } + + private fun cellAt(xPx: Float, yPx: Float): TerminalCell? { + val metrics = cellMetrics() ?: return null + return TerminalSelectionGeometry.cellAt(xPx, yPx, metrics, stockView.topRow) + } + + private fun showCopyAffordance() { + val existing = copyAffordance + if (existing != null) { + // The span moved: let the floating toolbar re-anchor rather than stacking a second mode. + existing.invalidateContentRect() + return + } + copyAffordance = startActionMode( + TerminalSelectionActionMode( + contentRect = { highlightRects().firstOrNull() }, + onCopy = { + copySelection() + selection.clear() + }, + onDismissed = { + copyAffordance = null + selection.clear() + }, + ), + ActionMode.TYPE_FLOATING, + ) + } + + private fun hideCopyAffordance() { + // Null the field BEFORE finishing: finish() calls onDestroyActionMode, which calls back into + // selection.clear(). The controller's own "already clear" guard stops the recursion; this stops a + // second finish() on a dead mode. + val mode = copyAffordance ?: return + copyAffordance = null + mode.finish() + } + + /** + * Draw the children (the stock view paints every glyph) and then the selection highlight ON TOP. + * + * An overlay, not a renderer patch: plan §6.1 keeps rendering stock, and the renderer's own selection + * channel (`TerminalView.mDefaultSelectors`) is package-private to `com.termux.view` — see + * [TerminalSelectionPainter] for why reaching it was rejected. + */ + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + selectionPainter.draw(canvas, highlightRects()) + } + + /** + * The renderer's real cell metrics, or null before the renderer exists. Read through + * `TerminalRenderer`'s **public** `getFontWidth()`/`getFontLineSpacing()` accessors — see + * [TerminalCellMetrics] for why that beats reflection or a re-measured `Paint`. + */ + internal fun cellMetrics(): TerminalCellMetrics? { + val renderer = stockView.mRenderer ?: return null + return TerminalCellMetrics(renderer.fontWidth, renderer.fontLineSpacing) + } + + /** The stock view's padding, which the grid maths subtracts (plan §6.4). */ + internal fun horizontalPadding(): Int = stockView.paddingLeft + internal fun verticalPadding(): Int = stockView.paddingTop + + /** Take focus and raise the soft keyboard — the response to a tap on the terminal. */ + internal fun showSoftKeyboard() { + requestFocus() + val inputMethods = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + inputMethods?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + } + + // ── Autofill ───────────────────────────────────────────────────────────────────────────────────── + + /** + * Keep the whole terminal subtree out of the autofill structure. + * + * `TerminalView.autofill(AutofillValue)` writes the filled text straight into a null `mTermSession` + * (offsets 7-20), and the view declares itself fillable, so a password manager offering to fill it is + * a crash. `View.isImportantForAutofill()` walks the parent chain calling this getter and stops at the + * first `*_EXCLUDE_DESCENDANTS`, so answering here covers the stock child as well. Overriding rather + * than only setting the flag also means no later `setImportantForAutofill` can quietly re-enable it. + * + * Nothing is lost: a terminal has no fillable fields, and shell input is exactly the content that + * should never enter an autofill structure (plan §8 — no secret leakage). + */ + override fun getImportantForAutofill(): Int = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS + + // ── IME ────────────────────────────────────────────────────────────────────────────────────────── + + override fun onCheckIsTextEditor(): Boolean = true + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val sink = this.sink ?: return null + // VISIBLE_PASSWORD + NO_SUGGESTIONS: no autocorrect, no predictive text and no learned-word + // history of what was typed into a shell. NO_EXTRACT_UI/NO_FULLSCREEN keep the landscape IME + // from covering the terminal with a full-screen text box. + outAttrs.inputType = EditorInfo.TYPE_CLASS_TEXT or + EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or + EditorInfo.TYPE_TEXT_FLAG_NO_SUGGESTIONS + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or + EditorInfo.IME_FLAG_NO_FULLSCREEN or + EditorInfo.IME_ACTION_NONE + return RemoteTerminalInputConnection(view = this, sink = sink, cursorModes = { sink.cursorModes() }) + } + + // ── Keys ───────────────────────────────────────────────────────────────────────────────────────── + // The frame holds focus, so key events land here first. Terminal keys are forwarded into the stock + // view so the WebTermTerminalViewClient decision path runs (that is where DECCKM and the byte + // encoding live); system keys skip it entirely. + // + // The skip is not just a shortcut: `TerminalView.onKeyDown` returns TRUE unconditionally while + // `mEmulator` is still null (offsets 62-70). Routing BACK through it would silently swallow the key + // during the window before the emulator binds — and forever if a session never binds — leaving the + // user stuck on the terminal screen. Answering system keys from here keeps that exit always open. + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean = + if (event.isSystem) super.onKeyDown(keyCode, event) + else stockView.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event) + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean = + if (event.isSystem) super.onKeyUp(keyCode, event) + else stockView.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event) + + /** Lets the stock view close an active text selection with BACK before the IME/Activity sees it. */ + override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean = + stockView.onKeyPreIme(keyCode, event) || super.onKeyPreIme(keyCode, event) + + // ── Touch & layout ─────────────────────────────────────────────────────────────────────────────── + + /** + * Claim focus on touch-down without consuming the gesture — taps, long presses and the mouse-button + * paths still reach the stock view. + */ + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN && !isFocused) requestFocus() + return super.dispatchTouchEvent(event) + } + + /** + * Take vertical drags away from the stock view. + * + * Answering true makes `ViewGroup` send the stock view `ACTION_CANCEL` and route the remainder of the + * gesture to [onTouchEvent] here — so `mGestureRecognizer` never observes a scroll, cannot call + * `TerminalView$1.onScroll` and cannot start the `TerminalView$1$1` fling, which is what makes the + * crashing `doScroll` unreachable rather than merely unlikely. Taps and pinches are never claimed. + */ + override fun onInterceptTouchEvent(event: MotionEvent): Boolean = routeTouch(event) + + /** The rest of a claimed drag. Returning true keeps the gesture here until it ends. */ + override fun onTouchEvent(event: MotionEvent): Boolean = + routeTouch(event) || scrollGesture.isOwned || super.onTouchEvent(event) + + /** + * The external-mouse wheel, the second stock entry into `doScroll` (offsets 43-55). `ViewGroup` + * offers generic pointer events to children first, so this override is the only place ahead of it. + */ + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (event.isFromSource(InputDevice.SOURCE_MOUSE) && event.action == MotionEvent.ACTION_SCROLL) { + eventInFlight = event + try { + scrollGesture.onMouseWheel(scrollUp = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0f) + } finally { + eventInFlight = null + } + return true + } + return super.dispatchGenericMotionEvent(event) + } + + /** + * Widened to public (the `View` callback is `protected`) so the size-change behaviour — including the + * selection drop below — is driven directly by a JVM test, exactly like the other overrides on this + * frame. Nothing in production calls it; the platform does. + */ + public override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + // A new size means a new grid and an emulator reflow, so the cells the span names are about to hold + // different text. Dropping the selection is the honest response — re-mapping it across a reflow is + // guesswork, and keeping it would let Copy return text the user never highlighted. + selection.clear() + sink?.onViewSize(width, height) + } + + /** + * With a live selection every drag EXTENDS it; otherwise the drag goes to the scroll gesture. + * + * The branch is what makes the feature usable on a touch screen: without it the very drag that should + * grow the selection would instead be claimed by [TerminalScrollGesture] and — inside an + * alternate-screen TUI — typed into the shell as arrow keys. + */ + private fun routeTouch(event: MotionEvent): Boolean { + if (selection.isActive) return routeSelectionTouch(event) + eventInFlight = event + try { + return scrollGesture.onTouch(TouchFacts.of(event)) + } finally { + eventInFlight = null + } + } + + private fun routeSelectionTouch(event: MotionEvent): Boolean = when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + // A second finger is a pinch, which belongs to the stock ScaleGestureDetector (safe: no session + // deref) and is where our onScale veto is enforced. + if (event.pointerCount > SINGLE_POINTER) { + isExtendingSelection + } else { + isExtendingSelection = true + extendSelectionTo(event.x, event.y) + true + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + val wasExtending = isExtendingSelection + isExtendingSelection = false + wasExtending + } + + // ACTION_DOWN is deliberately NOT claimed: the stock gesture detector has to keep seeing it so it + // can still resolve the gesture into a tap (which dismisses the selection) or another long press + // (which re-anchors it). Claiming it would make both impossible. + else -> false + } + + private fun extendSelectionTo(xPx: Float, yPx: Float) { + cellAt(xPx, yPx)?.let(selection::extendTo) + } + + /** + * Stock `sendMouseEventCode(event, MOUSE_WHEEL{UP,DOWN}_BUTTON, true)`, offsets 0-100: report the + * wheel at the cell the gesture started on (one anchor per down-time) so a program tracking the mouse + * sees a stable position rather than one that drifts with the finger. + */ + private fun sendMouseWheel(up: Boolean) { + val emulator = stockView.mEmulator ?: return + val event = eventInFlight ?: return + val cell = stockView.getColumnAndRow(event, false) + var column = cell[COLUMN_INDEX] + CELL_ORIGIN + var row = cell[ROW_INDEX] + CELL_ORIGIN + if (wheelAnchorDownTime == event.downTime) { + column = wheelAnchorColumn + row = wheelAnchorRow + } else { + wheelAnchorDownTime = event.downTime + wheelAnchorColumn = column + wheelAnchorRow = row + } + val button = + if (up) TerminalEmulator.MOUSE_WHEELUP_BUTTON else TerminalEmulator.MOUSE_WHEELDOWN_BUTTON + emulator.sendMouseEvent(button, column, row, true) + } + + private companion object { + /** Terminal body text, in sp so it follows the system font scale. */ + const val TEXT_SIZE_SP: Float = 12f + + /** Guard rails so an extreme font scale cannot produce an unusable (or zero-width) cell. */ + const val MIN_TEXT_SIZE_PX: Int = 8 + const val MAX_TEXT_SIZE_PX: Int = 64 + + /** + * Used when `ViewConfiguration` cannot be resolved (or reports 0, which would make every touch a + * drag). The platform's own default `scaledTouchSlop` at mdpi, so behaviour degrades sensibly + * rather than turning taps into scrolls. + */ + const val FALLBACK_TOUCH_SLOP_PX: Int = 8 + + /** The renderer has not measured a cell yet. */ + const val NO_CELL_HEIGHT: Int = 0 + + /** Stock `mTopRow == 0` means "showing the live screen"; negative walks back into scrollback. */ + const val NEWEST_ROW: Int = 0 + + /** No wheel gesture has been anchored yet (stock initialises `mMouseStartDownTime` to -1). */ + const val NO_DOWN_TIME: Long = -1L + + /** Nothing scrolled off the top since the last screen update. */ + const val NO_SCROLL: Int = 0 + + /** A second pointer means a pinch, not a drag. */ + const val SINGLE_POINTER: Int = 1 + + /** `getColumnAndRow` returns `[column, row]`, both 0-based; the wire protocol is 1-based. */ + const val COLUMN_INDEX: Int = 0 + const val ROW_INDEX: Int = 1 + const val CELL_ORIGIN: Int = 1 + + fun defaultTextSizePx(context: Context): Int = TypedValue + .applyDimension(TypedValue.COMPLEX_UNIT_SP, TEXT_SIZE_SP, context.resources.displayMetrics) + .roundToInt() + .coerceIn(MIN_TEXT_SIZE_PX, MAX_TEXT_SIZE_PX) + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalInputConnection.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalInputConnection.kt new file mode 100644 index 0000000..9312bd3 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalInputConnection.kt @@ -0,0 +1,104 @@ +package wang.yaojia.webterm.terminalview + +import android.view.KeyEvent +import android.view.View +import android.view.inputmethod.BaseInputConnection + +/** + * BLOCKER 1, the soft-keyboard half — our own `InputConnection`, because the stock one cannot work + * without a `TerminalSession`. + * + * Traced in the pinned artifact (`TerminalView$2`, the anonymous `BaseInputConnection` returned by + * `TerminalView.onCreateInputConnection`): + * `commitText` → `sendTextToTerminal` → `TerminalView.inputCodePoint(...)`, and `inputCodePoint` + * **returns immediately at offset 59–66 when `mTermSession == null`**. So with our forked session the + * stock connection is not merely crash-prone, it is a black hole: everything typed on the soft keyboard + * would vanish with no error. (`deleteSurroundingText` is worse — it round-trips through + * `sendKeyEvent`, whose stock landing zone is the same dead path.) + * + * This replacement keeps the parts of `BaseInputConnection` that matter for composition (CJK/emoji IMEs + * build text in the editable before committing) and redirects the delivery to [TerminalInputSink], i.e. + * `RemoteTerminalSession.writeInput` → `engineSend(Input(..))`. `mTermSession` is not referenced at all. + * + * @param view the host view — `BaseInputConnection` needs it for the editable and for key dispatch. + */ +internal class RemoteTerminalInputConnection( + view: View, + private val sink: TerminalInputSink, + private val cursorModes: () -> TerminalCursorModes?, +) : BaseInputConnection(view, /* fullEditor = */ true) { + + /** + * The IME committed text (the normal typing path, and the end of a CJK composition). Mirrors + * Termux's order: let the base class fold the commit into the editable so composing state resolves + * correctly, then drain the editable to the wire and clear it — the terminal, not the editable, is + * the document. + */ + override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { + super.commitText(text, newCursorPosition) + drainToTerminal(fallback = text) + return true + } + + /** Composition ended (IME switch, focus loss): flush whatever was staged rather than dropping it. */ + override fun finishComposingText(): Boolean { + super.finishComposingText() + drainToTerminal(fallback = null) + return true + } + + /** + * Backspace from the IME. Resolved through Termux's [com.termux.terminal.KeyHandler] (via the same + * decoder as a hardware key) so the byte matches what a physical Backspace sends — `DEL`, or `ESC + * DEL` under Alt — instead of a hand-rolled guess. + */ + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + repeat(beforeLength.coerceAtLeast(0)) { sendKeyCode(KeyEvent.KEYCODE_DEL) } + return super.deleteSurroundingText(beforeLength, afterLength) + } + + /** + * Some IMEs deliver Enter/Backspace/arrows as key events rather than text. Handled here rather than + * dispatched into the view, so this path can never re-enter the stock terminal handling. + */ + override fun sendKeyEvent(event: KeyEvent?): Boolean { + if (event == null) return false + if (event.action != KeyEvent.ACTION_DOWN) return true + sendKeyCode(event.keyCode, KeyEventFacts.of(event)) + return true + } + + private fun sendKeyCode(keyCode: Int, facts: KeyEventFacts = PLAIN_KEY) { + val modes = cursorModes() + val routing = TerminalKeyDecoder.decodeKeyDown( + keyCode = keyCode, + facts = facts, + appConsumed = false, + cursorKeysApplication = modes?.cursorKeysApplication ?: false, + keypadApplication = modes?.keypadApplication ?: false, + ) + if (routing is KeyRouting.Send) sink.sendInput(routing.bytes) + } + + private fun drainToTerminal(fallback: CharSequence?) { + val staged = editable + val content: CharSequence = if (staged != null && staged.isNotEmpty()) staged else fallback ?: return + val bytes = TerminalInputBytes.encodeText(content) + if (bytes.isNotEmpty()) sink.sendInput(bytes) + staged?.clear() + } + + private companion object { + /** An unmodified, non-system key with no keymap character — the shape of an IME-synthesised key. */ + val PLAIN_KEY = KeyEventFacts( + isSystemKey = false, + ctrlPressed = false, + altPressed = false, + shiftPressed = false, + numLockOn = false, + functionPressed = false, + metaState = 0, + unicodeChar = { 0 }, + ) + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt index 981c19a..e0a3608 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield import java.util.ArrayDeque import java.util.concurrent.Executors import wang.yaojia.webterm.wire.ClientMessage @@ -30,29 +29,62 @@ import wang.yaojia.webterm.wire.ClientMessage * `:wire-protocol` ([ClientMessage]) — the `SessionEvent → ByteArray` decode is A21's, not here * (boundary note §3). * - * ### Single-writer confinement (plan §6.2 mustFix) - * `TerminalEmulator`/`TerminalBuffer` is single-writer, read by the renderer on the UI thread. Every - * mutation (append, resize, pending flush) is serialized onto ONE confined dispatcher via an ordered - * command [Channel] — mirroring Termux's background reader thread — so a multi-MB ring replay never - * blocks the UI thread (ANR) and never races the on-screen draw. Only [onScreenUpdated] is posted to - * [mainDispatcher]. Ordering is exactly submission order (FIFO channel + one consumer). + * ### Single-writer confinement, and where the writer actually runs (plan §6.2 — CORRECTED 2026-07-30) + * There is exactly ONE writer: an ordered command [Channel] drained by a single consumer on + * [confinedDispatcher], so every mutation (append, resize, pending flush) happens in strict submission + * order and never concurrently with another mutation. What CHANGED is where the mutation itself executes: + * the consumer hops to [mainDispatcher] for the emulator call and nothing else. + * + * **Why it has to.** The stock renderer reads [TerminalBuffer] on the UI thread during `onDraw`, and the + * pinned Termux artifacts contain **no synchronisation whatsoever** — `monitorenter` appears nowhere in + * `TerminalEmulator`, `TerminalBuffer`, `TerminalRow` or `TerminalRenderer`. Upstream Termux is safe not + * by locking but by **thread identity**: its background reader only fills a `ByteQueue`, and + * `TerminalSession$MainThreadHandler.handleMessage` is what calls `TerminalEmulator.append` — on the main + * thread — while `TerminalView.updateSize()` resizes from the main thread too. So upstream has no + * concurrent reader at all, and this fork's earlier "single writer racing the UI-thread renderer, torn + * reads self-correct next frame" note was wrong twice over: it did not match upstream, and the tear is + * not cosmetic. `TerminalRenderer.render` caches its column bound from `TerminalEmulator.mColumns` + * (offsets 14-18) and then indexes `TerminalRow.getStyle(column)` — a raw `long[]` sized when the row was + * allocated — while `TerminalEmulator.resize` publishes the new `mColumns` (offsets 97-106) BEFORE + * `resizeScreen()` reallocates the rows (offset 160). A draw landing in that window throws + * `ArrayIndexOutOfBoundsException: length=oldColumns; index=oldColumns` on the UI thread: a process kill, + * observed on a real emulator. `TerminalBuffer.resize` is non-atomic in five more ways besides (it + * republishes `mLines`, then `mTotalRows`/`mScreenRows`/`mScreenFirstRow`/`mActiveTranscriptRows`), one of + * which fails as `IllegalArgumentException` out of `externalToInternalRow`; and `TerminalRow.setChar` + * swaps `mText` for a larger array, which is the same hazard on the append path. + * + * **Why this still honours §6.2's mustFix (no ANR on a multi-MB replay).** §6.2 forbids a *synchronous + * multi-MB* append on the main thread. The append is still [APPEND_CHUNK_BYTES]-chunked, and each chunk is + * its OWN main-thread work item, so the looper regains control between chunks: input and frames interleave + * with the replay instead of waiting behind it. No single work item is long, which is what "does not ANR" + * actually requires — not "runs on another thread". + * + * **Deadlock safety.** [confinedDispatcher] is never held while waiting on anything except the main hop + * itself, and the main thread never waits on the writer (there is no lock in either direction). The + * consumer is the only caller of the emulator, so an emulator callback re-entering this class (title, + * bell, DA/DSR reply via [termOutput]) runs on the main thread inside the hop and must therefore stay + * non-blocking — [engineSend] is documented as safe to call from any thread and must not block. * * The emulator is **not** exposed to the stock renderer until the initial [pendingOutput] flush has - * COMPLETED: the Bind command flushes the queue on the confined thread first, THEN posts a single Main - * action that publishes `mEmulator` to the view AND fires the first screen update — so the renderer - * never reads [TerminalBuffer] while the confined thread is still appending at the bind moment (§6.2). + * COMPLETED: the Bind command flushes the queue first, THEN publishes `mEmulator` to the view and fires + * the first screen update, so the renderer never sees a half-replayed buffer at the bind moment. * One malformed/hostile escape byte cannot freeze the terminal either: [consumeCommands] survives a * per-command throw and keeps draining (only cancellation propagates). * * @param engineSend the outbound sink — every [ClientMessage] produced here (Input/Resize) is handed to - * it. A21 bridges it to the engine's ordered send pump (§6.3). Must be safe to call from any thread. + * it. A21 bridges it to the engine's ordered send pump (§6.3). Must be safe to call from any thread, + * and must not block: it can be invoked from inside an emulator callback on the main thread. * @param onTitleChanged raw OSC 0/2 title delegate. Passed through UNsanitized — :terminal-view has no - * `:session-core` edge, so :app wires this to `TitleSanitizer` (plan §6.5 / boundary note §3). - * @param onBell OSC/ctrl-G bell delegate (the view may buzz/flash). - * @param appendDispatcher the confined single-writer dispatcher (default: a private single-thread - * executor). Injected as a `StandardTestDispatcher` in unit tests so the seam drives under virtual time. - * @param mainDispatcher where [onScreenUpdated] is posted (default `Main.immediate`; a test dispatcher - * in unit tests so no real main looper is needed). + * `:session-core` edge, so :app wires this to `TitleSanitizer` (plan §6.5 / boundary note §3). Called on + * [mainDispatcher] from inside `append`; must not block. + * @param onBell OSC/ctrl-G bell delegate (the view may buzz/flash). Same threading note as + * [onTitleChanged]. + * @param appendDispatcher the confined dispatcher that SEQUENCES commands and slices appends (default: a + * private single-thread executor). It no longer touches emulator state — [mainDispatcher] does. Injected + * as a `StandardTestDispatcher` in unit tests so the seam drives under virtual time. + * @param mainDispatcher the renderer's own thread: where every emulator mutation and every + * [onScreenUpdated] runs (default `Main.immediate`; a test dispatcher in unit tests so no real main + * looper is needed). */ public class RemoteTerminalSession( private val engineSend: (ClientMessage) -> Unit, @@ -64,7 +96,10 @@ public class RemoteTerminalSession( appendDispatcher: CoroutineDispatcher? = null, private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, ) { - /** Emulator-originated output sink + title/clipboard/bell delegate. Runs on the confined thread. */ + /** + * Emulator-originated output sink + title/clipboard/bell delegate. Every method here is called from + * inside `TerminalEmulator.append`, i.e. on [mainDispatcher] — so none of them may block. + */ private val termOutput: TerminalOutput = object : TerminalOutput() { override fun write(data: ByteArray?, offset: Int, count: Int) { if (data == null || count <= 0) return @@ -89,7 +124,7 @@ public class RemoteTerminalSession( public val emulator: TerminalEmulator = TerminalEmulator(termOutput, initialCols, initialRows, transcriptRows, NoOpTerminalSessionClient()) - // ── Confinement plumbing (all mutable state below is touched ONLY on the confined thread) ──────── + // ── Sequencing plumbing (all mutable state below is touched ONLY on the confined consumer) ─────── private val ownedExecutor = if (appendDispatcher == null) { Executors.newSingleThreadExecutor { r -> Thread(r, "webterm-term-append").apply { isDaemon = true } } } else { @@ -115,7 +150,7 @@ public class RemoteTerminalSession( /** * Feed remote WS output into the emulator. Never blocks the caller (the command channel is * UNLIMITED) and never mutates the emulator on the caller's thread — the bytes are appended on the - * confined dispatcher. Output arriving before [attachView]/[bind] is queued and replayed in order + * renderer's own thread, chunked. Output arriving before [attachView]/[bind] is queued and replayed in order * (§6.2); the `ESC[0m` replay prefix is passed through, never stripped. */ public fun feedRemote(bytes: ByteArray) { @@ -125,13 +160,20 @@ public class RemoteTerminalSession( /** * Latest-writer-wins resize (§6.4). Resolves the emulator's local buffer reflow AND emits a - * `Resize` to the server (→ SIGWINCH) — but only when [dims] actually change from [lastSentDims] - * and are positive. The dedup + `emulator.resize` are serialized on the confined thread so a resize - * never races an append. + * `Resize` to the server (→ SIGWINCH) — normally only when the dims actually change from + * [lastSentDims] and are positive. The dedup runs on the confined consumer so a resize never races an + * append; the reflow itself runs on [mainDispatcher] so it never races a draw either (`onResize`). + * + * @param force emit even when the dims are unchanged. This is what makes the §6.4 reclaim work: a + * shared PTY has ONE size, so a device coming back to the foreground has to re-assert dims it has + * already sent in order to take that size back from whichever device wrote last. Without this the + * re-assert is silently swallowed here and the returning device stays letterboxed. Reserved for + * that deliberate reclaim — ordinary layout churn must NOT set it, or every inset change becomes a + * wire frame and a SIGWINCH. */ - public fun updateSize(cols: Int, rows: Int) { + public fun updateSize(cols: Int, rows: Int, force: Boolean = false) { if (cols < TerminalGridMath.MIN_DIMENSION || rows < TerminalGridMath.MIN_DIMENSION) return - commands.trySend(TerminalCommand.Resize(cols, rows)) + commands.trySend(TerminalCommand.Resize(cols, rows, force)) } // ── Outbound: typed / key-bar bytes → wire ─────────────────────────────────────────────────────── @@ -158,11 +200,11 @@ public class RemoteTerminalSession( // ── View binding (pendingOutput flush) ─────────────────────────────────────────────────────────── /** - * Bind the on-screen view. The emulator is NOT published to the stock renderer synchronously here - * (on Main): the confined thread may still be flushing [pendingOutput] into the buffer, and the - * renderer reads it during draw — publishing now would race that flush (§6.2). Instead the publish - * is routed through the command channel so it lands on [mainDispatcher] only AFTER the initial flush - * completes, together with the first screen update. The sink then invalidates the view on every feed. + * Bind the on-screen view. The emulator is NOT published to the stock renderer synchronously here: + * [pendingOutput] may still hold an unflushed ring replay, and a renderer that can see the buffer + * mid-replay paints a half-restored screen. So the publish is routed through the command channel and + * lands only AFTER the initial flush completes, together with the first screen update. The sink then + * invalidates the view on every feed. */ public fun attachView(view: RemoteTerminalView) { view.session = this @@ -175,10 +217,10 @@ public class RemoteTerminalSession( } /** - * Testable bind seam: register the [onScreenUpdated] sink, flush pending output on the confined - * thread, THEN publish the emulator ([publishEmulator]) + fire the first screen update on the main - * dispatcher. Production goes through [attachView] (which supplies [publishEmulator]); unit tests - * call this directly (no android View needed) and default [publishEmulator] to a no-op. + * Testable bind seam: register the [onScreenUpdated] sink, flush pending output, THEN publish the + * emulator ([publishEmulator]) + fire the first screen update. Production goes through [attachView] + * (which supplies [publishEmulator]); unit tests call this directly (no android View needed) and + * default [publishEmulator] to a no-op. */ internal fun bind(publishEmulator: () -> Unit = {}, onScreenUpdated: () -> Unit) { commands.trySend(TerminalCommand.Bind(publishEmulator, onScreenUpdated)) @@ -191,14 +233,14 @@ public class RemoteTerminalSession( ownedExecutor?.shutdownNow() } - // ── The single confined consumer (one writer to the emulator) ──────────────────────────────────── + // ── The single confined consumer (the one writer; it mutates on [mainDispatcher]) ───────────────── private suspend fun consumeCommands() { for (command in commands) { try { when (command) { is TerminalCommand.Feed -> onFeed(command.bytes) - is TerminalCommand.Resize -> onResize(command.cols, command.rows) + is TerminalCommand.Resize -> onResize(command.cols, command.rows, command.force) is TerminalCommand.Bind -> onBind(command.publishEmulator, command.onScreenUpdated) TerminalCommand.Unbind -> { bound = false @@ -232,27 +274,39 @@ public class RemoteTerminalSession( while (pendingOutput.isNotEmpty()) { appendChunked(pendingOutput.removeFirst()) } - // The flush is now COMPLETE on this confined thread. Only now do we hand the emulator to the - // stock renderer AND fire the first screen update — both on the main dispatcher, in one action - // ordered strictly after the flush. The renderer therefore never reads TerminalBuffer while the - // confined thread is still appending at the bind moment (§6.2 single-writer-vs-UI-read). + // The flush is now COMPLETE. Only now do we hand the emulator to the stock renderer AND fire the + // first screen update — one main-thread action ordered strictly after the last append, so the + // renderer's first frame sees the whole replay rather than a partially restored screen. withContext(mainDispatcher) { publishEmulator() onScreenUpdated() } } - private fun onResize(cols: Int, rows: Int) { + private suspend fun onResize(cols: Int, rows: Int, force: Boolean) { val next = TerminalGridSize(cols, rows) - if (next == lastSentDims) return - emulator.resize(cols, rows) // local buffer reflow (confined-thread single writer) + // `force` is the §6.4 reclaim: unchanged dims must still reach the wire to win the shared PTY + // size back. Everything else is deduped here as the last line of defence against layout churn. + if (next == lastSentDims && !force) return + // The reflow rewrote the on-screen cells, so the repaint rides along in the SAME main-thread work + // item as the mutation: no frame can be drawn between them, and without a repaint the user keeps + // looking at the old wrap points until the next byte arrives (on an idle Claude session, minutes). + val notifyScreenUpdated = screenUpdateSink + withContext(mainDispatcher) { + emulator.resize(cols, rows) // local buffer reflow — on the renderer's own thread + notifyScreenUpdated?.invoke() + } engineSend(ClientMessage.Resize(cols, rows)) // → server SIGWINCH lastSentDims = next } /** - * Append [bytes] to the emulator in [APPEND_CHUNK_BYTES] slices, yielding between chunks so a - * multi-MB replay stays cooperative (cancellable, and never monopolizes the confined thread). + * Append [bytes] to the emulator in [APPEND_CHUNK_BYTES] slices, one main-thread work item per slice. + * + * The slicing is what keeps a multi-MB ring replay off the ANR path (§6.2): the main looper regains + * control between chunks, so frames and input interleave with the replay rather than queueing behind + * it. It also keeps the loop cooperatively cancellable — `withContext` is a suspension point. + * * UTF-8 continuation state is buffered inside the emulator across `append` calls, so slicing at an * arbitrary byte boundary never corrupts a multi-byte character. */ @@ -261,9 +315,8 @@ public class RemoteTerminalSession( while (offset < bytes.size) { val end = minOf(offset + APPEND_CHUNK_BYTES, bytes.size) val chunk = if (offset == 0 && end == bytes.size) bytes else bytes.copyOfRange(offset, end) - emulator.append(chunk, chunk.size) + withContext(mainDispatcher) { emulator.append(chunk, chunk.size) } offset = end - if (offset < bytes.size) yield() } } @@ -302,7 +355,7 @@ public class RemoteTerminalSession( /** Ordered commands to the single confined consumer — FIFO submission order == emulator write order. */ private sealed interface TerminalCommand { class Feed(val bytes: ByteArray) : TerminalCommand - class Resize(val cols: Int, val rows: Int) : TerminalCommand + class Resize(val cols: Int, val rows: Int, val force: Boolean = false) : TerminalCommand class Bind(val publishEmulator: () -> Unit, val onScreenUpdated: () -> Unit) : TerminalCommand data object Unbind : TerminalCommand } diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt index cdba279..132bd30 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt @@ -3,54 +3,165 @@ package wang.yaojia.webterm.terminalview import android.content.Context import android.view.KeyEvent import com.termux.terminal.TerminalEmulator -import com.termux.view.TerminalView /** - * The on-screen terminal seam — a COMPOSITION wrapper around Termux's stock [TerminalView]. + * The on-screen terminal seam — a COMPOSITION wrapper around Termux's stock `TerminalView`. * * ### Deviation from plan §6.1 (recorded): composition, not subclass * §6.1 says "the Termux `TerminalView` is subclassed only to expose an onKeyCommand outlet + install - * the key-bar." But at the pinned v0.118.0, `com.termux.view.TerminalView` is declared - * `public final class` — it CANNOT be subclassed (verified: the Kotlin compiler rejects - * `: TerminalView(...)` with "This type is final"). We therefore WRAP a stock instance and bind our - * forked emulator through its PUBLIC `mEmulator` field instead of via `attachSession(TerminalSession)` - * (there is no `TerminalSession` to fork — it too is `final`, and forking a process is exactly what we - * must not do). Glyph rendering, cursor, selection, IME and scroll stay 100% stock — the wrapped view - * renders unchanged; we only redirect where the emulator comes from and where input goes. + * the key-bar." At the pinned v0.118.0 `com.termux.view.TerminalView` is declared `public final class`, + * so it CANNOT be subclassed. We therefore wrap a stock instance inside a [RemoteTerminalHostView] and + * bind our forked emulator through its public `mEmulator` field instead of via + * `attachSession(TerminalSession)` (there is no `TerminalSession` to fork — it too is `final`, and + * forking a process is exactly what we must not do). * - * The [terminalView] is what A21 puts in the Compose tree (`AndroidView { remote.terminalView }`); the - * key-bar / hardware-chord layer (A17) installs its handler through [onKeyCommand] (delivered via a - * `TerminalViewClient` A17 sets on [terminalView]) and reads [onViewSizeChanged] for the resize path. + * **Glyph rendering and the cursor stay 100% stock. Scrolling and text selection do not** — both stock + * implementations dereference the absent `TerminalSession`, so [RemoteTerminalHostView] owns vertical + * drags, and the long press is consumed ([WebTermTerminalViewClient.onLongPress]) and re-spent on a + * first-party selection layer ([TerminalSelectionController]) rather than on the stock `ActionMode`, whose + * Copy button is an NPE. [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member: + * which are reachable, which are proven safe, and which had to be intercepted. * - * DEVICE-QA (plan §7): rendering, IME, selection, link taps, and the real font-metric→grid resize - * (`TerminalRenderer.mFontWidth` is package-private → measured on-device and fed to [TerminalGridMath]) - * are verified on a device, not here. This class only has to compile and offer the binding seam. + * ### What this class owns + * It is the single place that knows which [RemoteTerminalSession] is bound, so it implements the + * [TerminalInputSink] every android-bound helper funnels into: + * - **keys** — [RemoteTerminalHostView] → stock `TerminalView` → [WebTermTerminalViewClient] → + * [TerminalKeyDecoder] → `writeInput`. Installing that client is what stops the guaranteed NPE on the + * first key press (`mClient` is dereferenced unguarded at `TerminalView.onKeyDown` offset 82–92). + * - **soft keyboard** — [RemoteTerminalHostView.onCreateInputConnection] → + * [RemoteTerminalInputConnection] → `writeInput`, replacing a stock connection that silently drops + * everything without a `TerminalSession`. + * - **resize** — [RemoteTerminalHostView.onSizeChanged] → [TerminalResizeDriver] → + * [RemoteTerminalSession.updateSize], which is the ONLY reason the server PTY ever learns the real + * grid: `TerminalView.updateSize()` early-returns without a session (offsets 18–25). + * - **selection** — [WebTermTerminalViewClient.onLongPress] → [RemoteTerminalHostView.beginSelectionAt], + * then [copySelection] → `ClipboardManager`. Paste is untouched: it arrives through the IME. + * + * DEVICE-QA (plan §7): rendering, IME/CJK composition, link taps, focus behaviour and grid parity against + * web/iOS on the same physical size are verified on a device (risk R5) — and for selection specifically, + * the floating Copy toolbar, the drag feel and the highlight's alignment with the glyphs. */ public class RemoteTerminalView(context: Context) { - /** The stock, final Termux view. Added to the Compose tree by A21; rendering is entirely its own. */ - public val terminalView: TerminalView = TerminalView(context, null) + private val sink = object : TerminalInputSink { + override fun appConsumesKey(keyCode: Int, event: KeyEvent): Boolean = + onKeyCommand?.invoke(keyCode, event) == true + + override fun cursorModes(): TerminalCursorModes? = session?.let { + TerminalCursorModes( + cursorKeysApplication = it.emulator.isCursorKeysApplicationMode, + keypadApplication = it.emulator.isKeypadApplicationMode, + ) + } + + override fun sendInput(data: String) { + session?.writeInput(data) + } + + override fun onTapped() { + terminalView.onTapped() + } + + override fun onLongPressAt(xPx: Float, yPx: Float) { + terminalView.beginSelectionAt(xPx, yPx) + } + + override fun onViewSize(widthPx: Int, heightPx: Int) { + resizeDriver.onViewSize( + widthPx = widthPx, + heightPx = heightPx, + horizontalPaddingPx = terminalView.horizontalPadding(), + verticalPaddingPx = terminalView.verticalPadding(), + ) + // Keep the §6.4 latest-writer-wins outlet firing AFTER the grid is pushed, so :app's + // reclaim (`notifyForegrounded`) acts on dims the server has already been told about. + onViewSizeChanged?.invoke(widthPx, heightPx) + } + } + + /** + * The `View` A21 puts in the Compose tree (`AndroidView { remote.terminalView }`). It is the frame + * described in [RemoteTerminalHostView], not the raw Termux view — owning focus is the only way to + * own the `InputConnection`, since the stock `TerminalView` is final. + */ + public val terminalView: RemoteTerminalHostView = RemoteTerminalHostView(context) + + // Declared before `session`, whose setter calls into it. + private val resizeDriver = TerminalResizeDriver( + cellMetrics = { terminalView.cellMetrics() }, + applyGrid = { grid, isReassert -> + session?.updateSize(grid.cols, grid.rows, force = isReassert) + }, + ) /** The forked session backing this view. Set by [RemoteTerminalSession.attachView]. */ public var session: RemoteTerminalSession? = null + set(value) { + field = value + // A new session means a new emulator at its default grid, so the grid this view already + // measured has to be pushed again even though the pixels never changed — a forced re-assert + // rather than a memo reset, so it survives the writer's own dedup too (§6.4). + resizeDriver.reassertLastGrid() + } /** - * Hardware-key outlet (A17 install point). A17 wires this into a `TerminalViewClient` set on - * [terminalView]; returning true consumes the event, otherwise arrows/Enter/Tab should be resolved - * via [RemoteTerminalSession.keyBytes] so DECCKM still emits `ESC O A` (plan §6.3). + * Hardware-key outlet (A17 install point). Returning true means `:app` handled AND already sent the + * key; returning false leaves it to Termux's `KeyHandler` so DECCKM still emits `ESC O A` + * (plan §6.3) — that fallback now runs inside [TerminalKeyDecoder] rather than inside the stock + * Termux path, which would have dereferenced the absent `TerminalSession`. */ public var onKeyCommand: ((keyCode: Int, event: KeyEvent) -> Boolean)? = null - /** Layout-change outlet (A17/A21 install point) — wires real font metrics into the resize path. */ + /** Layout-change outlet (A17/A21 install point) — the §6.4 device-switch reclaim hook. */ public var onViewSizeChanged: ((widthPx: Int, heightPx: Int) -> Unit)? = null - /** Point the stock renderer at the forked emulator (bypasses the process-forking `attachSession`). */ + /** + * Fires whenever a text selection appears or disappears, so `:app` can mirror it (e.g. enable a Copy + * button in the terminal toolbar). Purely optional: the module already shows its own floating Copy + * affordance, so a host that ignores this still has working copy-out. + */ + public var onSelectionChanged: ((isSelecting: Boolean) -> Unit)? = null + + init { + terminalView.sink = sink + // Installed BEFORE any emulator binds: touch, focus and the IME can all arrive first, and every + // one of those stock paths dereferences `mClient` without a null check. + terminalView.installClient(WebTermTerminalViewClient(sink)) + terminalView.onSelectionChanged = { isSelecting -> onSelectionChanged?.invoke(isSelecting) } + } + + // ── Copy-out (plan §6.5). The gesture path is self-contained; these are for `:app`'s own UI ──────── + + /** True while text is highlighted. */ + public val isSelectingText: Boolean get() = terminalView.isSelectingText + + /** + * Copy the highlighted text to the device clipboard. This is the ONLY path from terminal content to the + * clipboard (plan §8): selecting copies nothing, and OSC 52 host-clipboard writes stay declined. + * + * Safe to call with nothing selected — it answers [TerminalCopyResult.NOTHING_SELECTED] rather than + * clearing whatever the user already had. + */ + public fun copySelection(): TerminalCopyResult = terminalView.copySelection() + + /** Drop the selection (also what a tap on the terminal does). */ + public fun clearSelection() { + terminalView.clearSelection() + } + + /** Point the stock renderer at the forked emulator, then re-assert the measured grid on it. */ public fun bindEmulator(emulator: TerminalEmulator) { - terminalView.mEmulator = emulator + terminalView.bindEmulator(emulator) + // The emulator starts at the server's 80x24 default; the view may already know better. Both calls + // are needed and they do not overlap: the re-assert covers "already measured" (and pushes from the + // memo, so it does not wait for a layout pass that may never come), while onViewSize covers the + // first bind, when there is nothing memoised yet. Each is a no-op in the other's case. + resizeDriver.reassertLastGrid() + sink.onViewSize(terminalView.width, terminalView.height) } /** Invalidate/redraw the stock view for the current emulator state (posted on the main thread). */ public fun requestScreenUpdate() { - terminalView.onScreenUpdated() + terminalView.requestScreenUpdate() } } diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalClipboard.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalClipboard.kt new file mode 100644 index 0000000..576a1e8 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalClipboard.kt @@ -0,0 +1,76 @@ +package wang.yaojia.webterm.terminalview + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.PersistableBundle +import android.util.Log + +/** + * The device clipboard, behind a one-method seam so the copy DECISION is unit-testable without a framework + * clipboard and so there is exactly one place in the module that can write terminal text out of the app. + */ +internal interface TerminalClipboard { + + /** @return true when [text] is now on the clipboard; false when the platform refused. */ + fun put(text: String): Boolean +} + +/** + * [TerminalClipboard] over `android.content.ClipboardManager`. + * + * ### Security posture (plan §8, §6.5) + * Terminal output is untrusted and may be a secret — an API key echoed by a script, a token in a log line. + * Two rules follow and both are enforced here rather than trusted to callers: + * + * - **Only an explicit user copy reaches this class.** There is no automatic path: OSC 52 host-clipboard + * writes stay declined in [RemoteTerminalSession] (`onCopyTextToClipboard` is a no-op), and + * [TerminalSelectionController] refuses to write without a live selection. Selecting text writes nothing. + * - **The copied text is never logged.** Failures log the throwable TYPE only, matching + * [RemoteTerminalSession]'s precedent; the payload never reaches logcat. + * + * The clip is also marked `EXTRA_IS_SENSITIVE` on API 33+, which suppresses the system clipboard preview + * overlay. The user asked for the text, but nobody asked for it to be rendered over their screen for a + * bystander — and this class cannot tell a filename from a password, so it treats every terminal copy as + * sensitive. + */ +internal class SystemTerminalClipboard(private val context: Context) : TerminalClipboard { + + override fun put(text: String): Boolean { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (clipboard == null) { + Log.w(LOG_TAG, "no clipboard service available; nothing copied") + return false + } + val clip = ClipData.newPlainText(CLIP_LABEL, text).apply { markSensitive() } + return try { + clipboard.setPrimaryClip(clip) + true + } catch (e: RuntimeException) { + // OEM clipboard services throw here (SecurityException when not the focused app, + // IllegalStateException / TransactionTooLargeException on a very large clip). A refused copy is + // a reportable outcome, never a crash. Type only — the clip content must not reach logcat. + Log.w(LOG_TAG, "clipboard refused the copy: ${e.javaClass.simpleName}") + false + } + } + + private fun ClipData.markSensitive() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + + private companion object { + /** + * Shown by the system clipboard UI. Deliberately generic: it must not describe WHICH session or + * host the text came from. + */ + const val CLIP_LABEL: String = "Terminal selection" + + const val LOG_TAG: String = "TerminalSelection" + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytes.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytes.kt new file mode 100644 index 0000000..89fe3bd --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytes.kt @@ -0,0 +1,153 @@ +package wang.yaojia.webterm.terminalview + +import android.view.KeyCharacterMap +import android.view.KeyEvent +import com.termux.terminal.KeyHandler + +/** + * The pure byte arithmetic our forked session has to own, because the two stock Termux methods that + * normally do it both dead-end on the `TerminalSession` we do not have (plan §6.1 — no local process). + * + * Re-derived from the pinned artifact `terminal-view-v0.118.0.aar` / `terminal-emulator-v0.118.0.aar`: + * - `TerminalView.inputCodePoint(codePoint, ctrlDown, altDown)` — the Ctrl transform (offsets 148–318) + * and the dead-key aliases (offsets 319–374). It **early-returns at offset 59–66 when + * `mTermSession == null`**, which is permanently our case, so the whole path is dead for us. + * - `TerminalSession.writeCodePoint(prependEscape, codePoint)` — the `ESC` prefix for Alt and the UTF-8 + * encode (offsets 45–272). It **throws `IllegalArgumentException`** for a surrogate/out-of-range code + * point (offsets 0–44); we return `null` instead, because a garbled key event must never take down + * the key path. + * + * Everything here is pure Kotlin over ints and strings — no view, no session, no android call at + * runtime (the `KeyEvent`/`KeyCharacterMap` constants are Java compile-time constants and inline). + * Bytes leave as a `String` because that is the wire shape (`ClientMessage.Input`) and the transport + * encodes UTF-8 once, at the boundary. + */ +internal object TerminalInputBytes { + + /** `ESC` (0x1B) — the Alt prefix and the lead byte of every CSI/SS3 sequence. */ + const val ESC: String = "\u001b" + + /** `KeyCharacterMap.COMBINING_ACCENT` — set by `getUnicodeChar` when the key is a dead key. */ + val COMBINING_ACCENT: Int = KeyCharacterMap.COMBINING_ACCENT + + /** All Ctrl bits — stripped before asking the keymap for a printable char (Termux offset 357). */ + val META_CTRL_MASK: Int = KeyEvent.META_CTRL_MASK + + /** The Alt bits Termux also strips unless NumLock is on (offsets 362–375: `18`). */ + val META_ALT_BITS: Int = KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON + + /** The Shift bits Termux re-adds when shift is held (offsets 388–398: `65`). */ + val META_SHIFT_BITS: Int = KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON + + /** Unicode's largest code point; above it `writeCodePoint` throws. */ + private const val MAX_CODE_POINT: Int = 0x10FFFF + private const val SURROGATE_FIRST: Int = 0xD800 + private const val SURROGATE_LAST: Int = 0xDFFF + + /** `LF`; a terminal Enter is `CR`, never `LF` (repo-wide gotcha, and Termux remaps it identically). */ + private const val LINE_FEED: Int = 10 + private const val CARRIAGE_RETURN: Int = 13 + + /** Ctrl-letter offsets: lowercase maps `a`→1 via `cp - 96`, uppercase `A`→1 via `cp - 64`. */ + private const val CTRL_LOWERCASE_OFFSET: Int = 96 + private const val CTRL_UPPERCASE_OFFSET: Int = 64 + + /** + * The Termux `KEYMOD_*` bitset for a key press — the second argument of [KeyHandler.getCode], which + * stays the single authority for the DECCKM/keypad key tables (plan §6.3). + */ + fun keyModifiers(ctrl: Boolean, alt: Boolean, shift: Boolean, numLock: Boolean): Int { + var mod = 0 + if (ctrl) mod = mod or KeyHandler.KEYMOD_CTRL + if (alt) mod = mod or KeyHandler.KEYMOD_ALT + if (shift) mod = mod or KeyHandler.KEYMOD_SHIFT + if (numLock) mod = mod or KeyHandler.KEYMOD_NUM_LOCK + return mod + } + + /** + * The meta state to hand `KeyEvent.getUnicodeChar(...)`, mirroring `TerminalView.onKeyDown` offsets + * 357–418: drop the Ctrl bits (and the Alt bits unless NumLock is on) so the keymap yields the base + * character rather than nothing, then re-assert Shift so the character comes back capitalised. + */ + fun effectiveMetaState(metaState: Int, shift: Boolean, numLockOn: Boolean): Int { + var bitsToClear = META_CTRL_MASK + if (!numLockOn) bitsToClear = bitsToClear or META_ALT_BITS + var effective = metaState and bitsToClear.inv() + if (shift) effective = effective or META_SHIFT_BITS + return effective + } + + /** + * One key press → the exact bytes the shell should receive, or `null` when the code point is not + * representable (dropped rather than thrown). + * + * Order matters and matches Termux: the Ctrl transform runs first, then the dead-key aliases, then + * the optional `ESC` prefix for Alt. + */ + fun encodeCodePoint(codePoint: Int, ctrlDown: Boolean, altDown: Boolean): String? { + if (codePoint < 0) return null + val controlled = if (ctrlDown) applyControl(codePoint) else codePoint + val resolved = applyDeadKeyAlias(controlled) + if (!isEncodable(resolved)) return null + val prefix = if (altDown) ESC else "" + return prefix + String(Character.toChars(resolved)) + } + + /** + * An IME commit (`commitText`/`finishComposingText`) → the bytes for the wire. + * + * This is the NET effect of Termux's `TerminalView$2.sendTextToTerminal`, which walks code points, + * remaps `LF`→`CR`, re-encodes C0 characters as "Ctrl + printable" and then feeds them back through + * the Ctrl transform — a round trip that provably returns the original C0 byte. Doing the net thing + * directly keeps one obvious implementation instead of two that must agree. + * + * Content is otherwise passed through verbatim (invariant #1/#9): CJK, emoji and surrogate pairs are + * never filtered, uppercased or normalised. + */ + fun encodeText(text: CharSequence): String { + if (text.isEmpty()) return "" + val out = StringBuilder(text.length) + var index = 0 + while (index < text.length) { + val codePoint = Character.codePointAt(text, index) + index += Character.charCount(codePoint) + out.appendCodePoint(if (codePoint == LINE_FEED) CARRIAGE_RETURN else codePoint) + } + return out.toString() + } + + /** `TerminalView.inputCodePoint` offsets 148–318, alias for alias. */ + private fun applyControl(codePoint: Int): Int = when (codePoint) { + in 'a'.code..'z'.code -> codePoint - CTRL_LOWERCASE_OFFSET + in 'A'.code..'Z'.code -> codePoint - CTRL_UPPERCASE_OFFSET + ' '.code, '2'.code -> 0 + '['.code, '3'.code -> 27 + '\\'.code, '4'.code -> 28 + ']'.code, '5'.code -> 29 + '^'.code, '6'.code -> 30 + '_'.code, '7'.code, '/'.code -> 31 + '8'.code -> 127 + else -> codePoint + } + + /** `TerminalView.inputCodePoint` offsets 319–374 — spacing modifier letters → their ASCII twins. */ + private fun applyDeadKeyAlias(codePoint: Int): Int = when (codePoint) { + MODIFIER_CIRCUMFLEX -> '^'.code + MODIFIER_GRAVE -> '`'.code + MODIFIER_TILDE -> '~'.code + else -> codePoint + } + + private fun isEncodable(codePoint: Int): Boolean = + codePoint in 0..MAX_CODE_POINT && codePoint !in SURROGATE_FIRST..SURROGATE_LAST + + /** U+02C6 MODIFIER LETTER CIRCUMFLEX ACCENT. */ + private const val MODIFIER_CIRCUMFLEX: Int = 710 + + /** U+02CB MODIFIER LETTER GRAVE ACCENT. */ + private const val MODIFIER_GRAVE: Int = 715 + + /** U+02DC SMALL TILDE. */ + private const val MODIFIER_TILDE: Int = 732 +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt new file mode 100644 index 0000000..f4ce42d --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt @@ -0,0 +1,51 @@ +package wang.yaojia.webterm.terminalview + +import android.view.KeyEvent + +/** + * The one outlet every input source in this module funnels into — hardware keys (via + * [WebTermTerminalViewClient]), the soft keyboard (via [RemoteTerminalInputConnection]) and layout + * changes (via [RemoteTerminalHostView]). + * + * It exists so the android-bound classes never reach for a `RemoteTerminalSession` (or, worse, Termux's + * `mTermSession`) directly: [RemoteTerminalView] is the only implementor and it is the single place + * where "which session is currently bound" is known. That keeps the §6.3 ordered-send model intact — + * everything ends at `RemoteTerminalSession.writeInput` → `engineSend(Input(..))`. + */ +internal interface TerminalInputSink { + + /** + * `:app`'s hardware-chord outlet (`RemoteTerminalView.onKeyCommand` → `HardwareKeyRouter`). Returns + * true when `:app` both handled AND already sent the key, so the decoder must not send it again. + */ + fun appConsumesKey(keyCode: Int, event: KeyEvent): Boolean + + /** The emulator's live DECCKM / keypad bits, or null when no session is bound yet. */ + fun cursorModes(): TerminalCursorModes? + + /** Raw bytes onto the wire, verbatim (invariant #9 — never filtered). */ + fun sendInput(data: String) + + /** + * The user tapped the terminal. Normally: take focus and raise the soft keyboard — but with a live + * text selection the tap dismisses it instead (the standard platform gesture), which is why this is a + * "tapped" event for the view to interpret rather than a "show the keyboard" command. + */ + fun onTapped() + + /** + * The user long-pressed at these view pixels: start a text selection there + * ([RemoteTerminalHostView.beginSelectionAt]). + * + * The long press stays CONSUMED by [WebTermTerminalViewClient] either way — this is a notification, not + * a decision. Stock text selection must remain unreachable (its Copy button dereferences the absent + * `TerminalSession`), so the press is spent on the first-party layer instead of being handed back. + */ + fun onLongPressAt(xPx: Float, yPx: Float) + + /** The host view was laid out at this pixel size (drives the §6.4 resize path). */ + fun onViewSize(widthPx: Int, heightPx: Int) +} + +/** The two emulator mode bits the Termux key tables need. Snapshotted so the decoder stays pure. */ +internal data class TerminalCursorModes(val cursorKeysApplication: Boolean, val keypadApplication: Boolean) diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoder.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoder.kt new file mode 100644 index 0000000..80fc899 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoder.kt @@ -0,0 +1,131 @@ +package wang.yaojia.webterm.terminalview + +import android.view.KeyEvent +import com.termux.terminal.KeyHandler + +/** + * What to do with one hardware key press. + * + * There is deliberately **no variant meaning "let the stock Termux path handle it"**. Every stock + * continuation past `TerminalViewClient.onKeyDown` dereferences `mTermSession`, which is null forever in + * this app (plan §6.1 — no local subprocess) and cannot be set (`TerminalSession` is `final` and its + * constructor builds a process I/O pipeline we must not have). Verified in `terminal-view-v0.118.0.aar` + * → `TerminalView.class`: `mTermSession.write(getCharacters())` at offset 149–160, `handleKeyCode` → + * `mTermSession.getEmulator()` at offset 15–19, `inputCodePoint` → `mTermSession.writeCodePoint` at + * offset 375–384. The absence of that variant is the fix: it makes the crash unrepresentable **on the key + * path**. It says nothing about the touch path, which bypasses `TerminalViewClient` entirely — see + * [RemoteTerminalHostView]'s stock-method audit for what guards that. + */ +internal sealed interface KeyRouting { + + /** Consume the key and put exactly these bytes on the wire. */ + data class Send(val bytes: String) : KeyRouting + + /** Consume the key and send nothing (already sent by `:app`, or the key has no terminal meaning). */ + data object Consumed : KeyRouting + + /** + * Hand the key to the HOST view's `super` — i.e. the Activity's BACK/volume/system handling. Never + * to Termux's terminal path. + */ + data object DeferToHostView : KeyRouting +} + +/** + * The subset of an `android.view.KeyEvent` the decision needs, lifted out so the decision itself is pure + * and unit-testable without a device or a mocked framework class. [unicodeChar] is the keymap lookup + * (`KeyEvent.getUnicodeChar(metaState)`), deferred because the meta state it takes is itself derived. + */ +internal class KeyEventFacts( + val isSystemKey: Boolean, + val ctrlPressed: Boolean, + val altPressed: Boolean, + val shiftPressed: Boolean, + val numLockOn: Boolean, + val functionPressed: Boolean, + val metaState: Int, + val unicodeChar: (metaState: Int) -> Int, +) { + companion object { + /** Read the facts off a real event. The only android-touching line in the key path. */ + fun of(event: KeyEvent): KeyEventFacts = KeyEventFacts( + isSystemKey = event.isSystem, + ctrlPressed = event.isCtrlPressed, + altPressed = event.isAltPressed, + shiftPressed = event.isShiftPressed, + numLockOn = event.isNumLockOn, + functionPressed = event.isFunctionPressed, + metaState = event.metaState, + unicodeChar = { meta -> event.getUnicodeChar(meta) }, + ) + } +} + +/** + * Turns a key press into a [KeyRouting], reproducing the useful half of `TerminalView.onKeyDown` + * (offsets 259–582) while routing every outcome away from `mTermSession`. + * + * Termux's [KeyHandler] stays the authority for the key tables, so DECCKM (`ESC O A` under + * application-cursor mode) and the keypad/modifier variants keep matching the web and iOS clients + * exactly — plan §6.3 forbids hand-rolling that table. + */ +internal object TerminalKeyDecoder { + + /** + * @param appConsumed `:app`'s hardware-chord outlet already handled and SENT this key + * (`RemoteTerminalView.onKeyCommand` → `HardwareKeyRouter`); emitting again would double-type. + * @param cursorKeysApplication the emulator's live DECCKM bit. + * @param keypadApplication the emulator's live DECKPAM bit. + */ + fun decodeKeyDown( + keyCode: Int, + facts: KeyEventFacts, + appConsumed: Boolean, + cursorKeysApplication: Boolean, + keypadApplication: Boolean, + ): KeyRouting { + if (appConsumed) return KeyRouting.Consumed + // BACK / HOME / volume must still reach the Activity, or the terminal screen becomes a trap. + if (facts.isSystemKey) return KeyRouting.DeferToHostView + + // 1. The Termux key table (arrows, Enter, Tab, F-keys, Home/End, keypad) — DECCKM-correct. + // Gated on !Fn exactly like TerminalView.onKeyDown offsets 319-333. + if (!facts.functionPressed) { + val keyMod = TerminalInputBytes.keyModifiers( + ctrl = facts.ctrlPressed, + alt = facts.altPressed, + shift = facts.shiftPressed, + numLock = facts.numLockOn, + ) + val mapped = KeyHandler.getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication) + if (!mapped.isNullOrEmpty()) return KeyRouting.Send(mapped) + } + + // 2. Otherwise it is a printable key: ask the keymap, then apply the Ctrl/Alt transform. + val effectiveMeta = TerminalInputBytes.effectiveMetaState( + metaState = facts.metaState, + shift = facts.shiftPressed, + numLockOn = facts.numLockOn, + ) + val raw = facts.unicodeChar(effectiveMeta) + if (raw == 0) return KeyRouting.Consumed + // A dead key (accent) — Termux composes it with the NEXT key using a package-private view field + // we cannot reach. Swallow it rather than emitting a lone spacing accent; real composition + // arrives through the IME path (RemoteTerminalInputConnection), which is the mobile text path. + if (raw and TerminalInputBytes.COMBINING_ACCENT != 0) return KeyRouting.Consumed + + val bytes = TerminalInputBytes.encodeCodePoint( + codePoint = raw, + ctrlDown = facts.ctrlPressed, + altDown = facts.altPressed, + ) ?: return KeyRouting.Consumed + return KeyRouting.Send(bytes) + } + + /** + * Key-up carries no bytes (the shell saw everything on key-down), so it is consumed — except for + * system keys, whose `up` must reach the Activity for BACK to fire. + */ + fun decodeKeyUp(facts: KeyEventFacts): KeyRouting = + if (facts.isSystemKey) KeyRouting.DeferToHostView else KeyRouting.Consumed +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriver.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriver.kt new file mode 100644 index 0000000..1a21588 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriver.kt @@ -0,0 +1,86 @@ +package wang.yaojia.webterm.terminalview + +/** + * The real cell metrics of the stock Termux renderer. + * + * `TerminalRenderer.mFontWidth` / `mFontLineSpacing` are package-private fields, but the pinned + * artifact also ships **public accessors** — `public float getFontWidth()` and + * `public int getFontLineSpacing()` (verified with `javap -p` on + * `terminal-view-v0.118.0.aar` → `TerminalRenderer.class`). So the metrics are read through plain public + * API: no reflection (which would break under R8/a version bump silently), no same-package accessor + * shim, and no re-measured `Paint` (which would drift from whatever the renderer actually draws with — + * exactly the cols×rows drift plan risk R5 warns about). + */ +internal data class TerminalCellMetrics(val fontWidthPx: Float, val lineSpacingPx: Int) + +/** + * BLOCKER 2 — the thing that makes `resize` happen at all. + * + * Nothing in the app ever called `RemoteTerminalSession.updateSize`, and the stock fallback cannot + * help: `TerminalView.updateSize()` early-returns when `mTermSession == null` (offsets 18–25 of + * `TerminalView.class`), which is permanently our case. So the server PTY sat at the 80×24 it is created + * with and every full-screen TUI rendered into the wrong box. + * + * This driver is the replacement path: layout size + real cell metrics → the pure, already-tested + * [TerminalGridMath.computeGridSize] → [applyGrid]. It is deliberately **pure of android**: the view + * supplies the pixels and the metrics, so the arithmetic and the skip-logic are JVM-unit-testable. + * + * ### Why the dedup lives here as well as on the confined writer + * `RemoteTerminalSession` already refuses to re-emit an unchanged grid, but layout fires far more often + * than the grid changes (every scroll-inset, IME show/hide, and each frame of a fold/multi-window drag). + * Stopping at the source keeps that churn off the command channel entirely, so rotation costs exactly + * one `Resize` frame per real grid change. The dedup is on the GRID, not the pixel size: growing by a + * few sub-cell pixels is not a resize. + * + * ### …and why a re-assert has to be FLAGGED rather than just forgetting the memo + * Two dedups in series can cancel the §6.4 reclaim entirely. Clearing this memo only re-opens the + * *first* gate; the identical grid then dies on the writer's own `lastSentDims`, which survives the + * rebind, so the returning device never re-asserts its size on the wire and the other device keeps the + * shared PTY. [reassertLastGrid] therefore re-pushes with `isReassert = true`, which the writer honours + * as "emit even though nothing changed", and deliberately LEAVES the memo in place so ordinary layout + * churn still cannot ride along behind the reclaim. + * + * Not thread-safe by design — it is driven from `onSizeChanged`, i.e. the main thread only. + */ +internal class TerminalResizeDriver( + private val cellMetrics: () -> TerminalCellMetrics?, + private val applyGrid: (grid: TerminalGridSize, isReassert: Boolean) -> Unit, +) { + private var lastGrid: TerminalGridSize? = null + + /** + * A layout pass reported [widthPx]×[heightPx]. Resolves the grid and pushes it iff it is valid and + * actually new. Pre-layout (zero) sizes and a not-yet-built renderer are dropped silently — they are + * the normal startup order, not an error. + */ + fun onViewSize(widthPx: Int, heightPx: Int, horizontalPaddingPx: Int, verticalPaddingPx: Int) { + val metrics = cellMetrics() ?: return + val grid = TerminalGridMath.computeGridSize( + viewWidthPx = widthPx, + viewHeightPx = heightPx, + horizontalPaddingPx = horizontalPaddingPx, + verticalPaddingPx = verticalPaddingPx, + fontWidthPx = metrics.fontWidthPx, + fontLineSpacingPx = metrics.lineSpacingPx, + ) ?: return + if (grid == lastGrid) return + lastGrid = grid + applyGrid(grid, false) + } + + /** + * Re-push the last measured grid as a forced re-assert (§6.4 latest-writer-wins). + * + * Fires from a lifecycle / focus / pane-show callback, not from `onSizeChanged` — there is no new + * layout pass to piggyback on, so the push comes from the memo rather than from a re-measure. Before + * anything has been measured there is nothing to assert and this is a no-op: the first real layout + * will push anyway. + * + * The memo is intentionally NOT cleared — see the class doc. Clearing it would let the next identical + * layout pass push a second time. + */ + fun reassertLastGrid() { + val grid = lastGrid ?: return + applyGrid(grid, true) + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGesture.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGesture.kt new file mode 100644 index 0000000..9c18279 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGesture.kt @@ -0,0 +1,202 @@ +package wang.yaojia.webterm.terminalview + +import android.view.KeyEvent +import android.view.MotionEvent +import com.termux.terminal.KeyHandler +import com.termux.terminal.TerminalEmulator +import kotlin.math.abs + +/** + * BLOCKER — the scroll path that had to be taken away from the stock view. + * + * `TerminalView.doScroll` (offsets 56-79 of the pinned `terminal-view-v0.118.0.aar`) turns a scroll into + * `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` **whenever the alternate screen buffer is active**, and + * `handleKeyCode` offsets 15-19 are `mTermSession.getEmulator()` with no null guard. `mEmulator` IS + * guarded (offset 4); `mTermSession` is not. Since this app has no `TerminalSession` at all (plan §6.1 — + * no local subprocess) that is a hard NPE on the UI thread, i.e. a process crash, for one finger-swipe + * inside vim / htop / less / man / the git-log pager / tmux — every TUI in the plan's own §7 device-QA + * list. `TerminalViewClient` has no callback anywhere on that path, so installing a client cannot help. + * Three stock entry points reach it: `TerminalView$1.onScroll` (offsets 103-110), + * `TerminalView$1$1.run` (the fling runnable, offset 120) and `onGenericMotionEvent` (offsets 43-55). + * + * The fix is ownership: [RemoteTerminalHostView] claims the vertical drag before the stock view sees it, + * so all three entry points become unreachable, and this class reproduces what `doScroll` *should* have + * done — the same three branches, in the same order, with the same arithmetic: + * + * | stock `doScroll` offsets | condition | this class | + * |--------------------------|-------------------------------|---------------------------------------------| + * | 26-53 | `isMouseTrackingActive()` | [TerminalScrollTarget.reportMouseWheel] | + * | 56-79 (**the crash**) | `isAlternateBufferActive()` | [TerminalScrollTarget.sendKeys], DECCKM-correct | + * | 86-130 | otherwise | [TerminalScrollTarget.scrollTranscript] | + * + * The mode bits are read off the **bound emulator** — which is the very object stock reaches for via + * `mTermSession.getEmulator()`, so the arrow form (CSI vs SS3) is identical, not re-derived. Termux's + * [KeyHandler] stays the authority for the key table (plan §6.3 forbids hand-rolling it). + * + * Pure of `android.view.View`: the pixels arrive as [TouchFacts] and the effects leave through + * [TerminalScrollTarget], so the whole decision is JVM-unit-testable — the same shape [KeyEventFacts] + * gives the key path. + */ +internal class TerminalScrollGesture( + private val touchSlopPx: Int, + private val cellHeightPx: () -> Int, + private val target: TerminalScrollTarget, +) { + private var owned = false + private var lastY = 0f + private var travelSinceDownPx = 0f + private var unspentTravelPx = 0f + + /** True once this gesture belongs to us; the stock view has been sent `ACTION_CANCEL` by then. */ + val isOwned: Boolean get() = owned + + /** + * Feed one touch sample. + * + * @return true once the gesture is ours. `ViewGroup` reads that from `onInterceptTouchEvent` as + * "cancel the child and route the rest here", which is exactly the guarantee we need: the stock + * `mGestureRecognizer` never sees a scroll, so it can neither call `doScroll` nor start a fling. + */ + fun onTouch(facts: TouchFacts): Boolean = when (facts.phase) { + TouchPhase.DOWN -> { + reset(facts.y) + false + } + + TouchPhase.MOVE -> onMove(facts) + + TouchPhase.END -> { + val wasOwned = owned + reset(facts.y) + wasOwned + } + + TouchPhase.OTHER -> owned + } + + /** + * One external-mouse wheel notch, replacing stock `onGenericMotionEvent` offsets 43-55 (which hands + * `±MOUSE_WHEEL_ROWS` straight to the crashing `doScroll`). + */ + fun onMouseWheel(scrollUp: Boolean) { + scrollRows(if (scrollUp) -MOUSE_WHEEL_ROWS else MOUSE_WHEEL_ROWS) + } + + /** Drop any half-finished gesture (view detach / emulator rebind). */ + fun reset() { + reset(lastY) + } + + private fun reset(y: Float) { + owned = false + lastY = y + travelSinceDownPx = 0f + unspentTravelPx = 0f + } + + private fun onMove(facts: TouchFacts): Boolean { + // A second finger means a pinch: that gesture belongs to the stock ScaleGestureDetector, which is + // safe (TerminalView$1.onScale touches no session) and is where our onScale veto is enforced. + if (facts.pointerCount > SINGLE_POINTER) return owned + + // Positive == the finger moved UP the screen, matching GestureDetector's `distanceY` + // (`previousFocusY - currentFocusY`) so every sign below is stock's sign. + val travelPx = lastY - facts.y + lastY = facts.y + + if (!owned) { + travelSinceDownPx += travelPx + if (abs(travelSinceDownPx) < touchSlopPx) return false + owned = true + // The slop travel is spent on the claim itself — GestureDetector likewise only starts + // reporting distance once the slop is crossed, so a claimed drag does not jump a row. + unspentTravelPx = 0f + return true + } + + val cellHeight = cellHeightPx() + // Before the renderer has measured a cell there is no row size to divide by. Keep the gesture + // (dropping it now would hand the rest of the drag back to the crashing stock path) and scroll + // nothing — which is also what stock does with a not-yet-laid-out view. + if (cellHeight <= 0) return true + + unspentTravelPx += travelPx + val rows = (unspentTravelPx / cellHeight).toInt() + unspentTravelPx -= rows * cellHeight + scrollRows(rows) + return true + } + + /** `doScroll(event, rows)` — same branch order, same per-row loop, minus the `mTermSession` deref. */ + private fun scrollRows(rows: Int) { + if (rows == 0) return + val emulator = target.emulator() ?: return + val up = rows < 0 + repeat(abs(rows)) { + when { + emulator.isMouseTrackingActive -> target.reportMouseWheel(up) + emulator.isAlternateBufferActive -> arrowKeyBytes(emulator, up)?.let(target::sendKeys) + else -> target.scrollTranscript(up) + } + } + } + + /** + * What stock `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` would have written — `KeyHandler.getCode` with + * the emulator's live DECCKM/keypad bits, so vim and htop still get `ESC O A` rather than `ESC [ A`. + */ + private fun arrowKeyBytes(emulator: TerminalEmulator, up: Boolean): String? = KeyHandler.getCode( + if (up) KeyEvent.KEYCODE_DPAD_UP else KeyEvent.KEYCODE_DPAD_DOWN, + NO_KEY_MODIFIERS, + emulator.isCursorKeysApplicationMode, + emulator.isKeypadApplicationMode, + ) + + companion object { + /** Stock `onGenericMotionEvent` scrolls three rows per wheel notch. */ + const val MOUSE_WHEEL_ROWS: Int = 3 + + /** `handleKeyCode` is called with a zero key-modifier bitset from `doScroll`. */ + private const val NO_KEY_MODIFIERS: Int = 0 + + private const val SINGLE_POINTER: Int = 1 + } +} + +/** Which part of a gesture a sample belongs to. Cancel counts as an end: the gesture is over either way. */ +internal enum class TouchPhase { DOWN, MOVE, END, OTHER } + +/** + * The subset of a `MotionEvent` the scroll decision needs, lifted out so the decision stays pure and + * unit-testable without a device — the same trick [KeyEventFacts] plays for keys. + */ +internal data class TouchFacts(val phase: TouchPhase, val y: Float, val pointerCount: Int) { + companion object { + fun of(event: MotionEvent): TouchFacts = TouchFacts( + phase = when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> TouchPhase.DOWN + MotionEvent.ACTION_MOVE -> TouchPhase.MOVE + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> TouchPhase.END + else -> TouchPhase.OTHER + }, + y = event.y, + pointerCount = event.pointerCount, + ) + } +} + +/** Where a resolved scroll lands. Implemented by [RemoteTerminalHostView] over the stock view. */ +internal interface TerminalScrollTarget { + + /** The bound emulator, or null before bind — the live source of the mode bits `doScroll` reads. */ + fun emulator(): TerminalEmulator? + + /** Stock `doScroll` offsets 86-130: move the local transcript window by one row and repaint. */ + fun scrollTranscript(up: Boolean) + + /** Stock `doScroll` offsets 66-79, rewritten to reach the wire instead of a null `TerminalSession`. */ + fun sendKeys(data: String) + + /** Stock `doScroll` offsets 36-53 → `sendMouseEventCode(event, MOUSE_WHEEL*_BUTTON, true)`. */ + fun reportMouseWheel(up: Boolean) +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionActionMode.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionActionMode.kt new file mode 100644 index 0000000..336a0a1 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionActionMode.kt @@ -0,0 +1,76 @@ +package wang.yaojia.webterm.terminalview + +import android.graphics.Rect +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import android.view.View +import kotlin.math.roundToInt + +/** + * The explicit Copy affordance: a floating `ActionMode` over the selection. + * + * This is plan §6.5's "Android `ActionMode` → `ClipboardManager`" with the one part that could not be kept — + * `TextSelectionCursorController` — replaced. It is our own `ActionMode.Callback2` on our own frame, so the + * menu it builds has one item that calls [onCopy], and the stock callback whose Copy is + * `mTermSession.onCopyTextToClipboard(...)` is never constructed. `TYPE_FLOATING` + [onGetContentRect] is the + * same shape stock uses (`startActionMode(callback, 1)`), which is what makes the toolbar hover over the + * selected text rather than take over the app bar. + * + * Paste is deliberately absent: it already works through the IME + * ([RemoteTerminalInputConnection] → `writeInput`), and a paste item here would only duplicate it. + * + * @param contentRect the selection's bounding box in view pixels, or null when nothing is highlighted. + * @param onCopy fired for the Copy item. The caller both copies and dismisses. + * @param onDismissed fired when the mode ends for ANY reason (Copy, back, tapping away). + */ +internal class TerminalSelectionActionMode( + private val contentRect: () -> TerminalSelectionRect?, + private val onCopy: () -> Unit, + private val onDismissed: () -> Unit, +) : ActionMode.Callback2() { + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + // android.R.string.copy is the platform's own localised "Copy", so :terminal-view needs no + // resources of its own for this. + menu.add(Menu.NONE, COPY_ITEM_ID, Menu.NONE, android.R.string.copy) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + return true + } + + /** Nothing to re-prepare: the single item is always enabled while a selection exists. */ + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + if (item.itemId != COPY_ITEM_ID) return false + onCopy() + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + onDismissed() + } + + /** + * Where the floating toolbar should point. Falls back to the platform default (the whole view) when the + * selection has scrolled out of sight, which is better than anchoring the toolbar off-screen. + */ + override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) { + val rect = contentRect() + if (rect == null) { + super.onGetContentRect(mode, view, outRect) + return + } + outRect.set( + rect.left.roundToInt(), + rect.top.roundToInt(), + rect.right.roundToInt(), + rect.bottom.roundToInt(), + ) + } + + private companion object { + /** Any non-zero id; there is only ever one item. */ + const val COPY_ITEM_ID: Int = 1 + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBuffer.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBuffer.kt new file mode 100644 index 0000000..82e7c53 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBuffer.kt @@ -0,0 +1,145 @@ +package wang.yaojia.webterm.terminalview + +import android.util.Log +import com.termux.terminal.TerminalEmulator + +/** + * Read-only access to whatever the emulator currently holds — the ONE seam the selection layer reads the + * terminal through. + * + * Two operations, both pure reads: what grid exists right now, and what text a span covers. Keeping it to + * an interface is what lets the state machine ([TerminalSelectionController]) be driven by a fake, while + * the real implementation is pinned against a real `TerminalEmulator` in the same JVM suite. + */ +internal interface TerminalSelectionBuffer { + + /** The live grid, or null before an emulator is bound. */ + fun bounds(): TerminalGridBounds? + + /** + * The text inside [span]. Never throws: an off-grid span is clamped and a missing emulator yields "". + * Implementations MUST NOT mutate anything (see [TerminalEmulatorSelectionBuffer] for why). + */ + fun textIn(span: TerminalSelectionSpan): String +} + +/** + * [TerminalSelectionBuffer] over the bound Termux emulator. + * + * ### Threading — how a read is kept off a concurrent mutation (§6.2) + * Every method here runs on the **main (render) thread**: the callers are touch/long-press/ActionMode + * callbacks and `dispatchDraw`. Since the 2026-07-30 repair, EVERY emulator mutation also runs on the main + * thread — [RemoteTerminalSession]'s single confined consumer hops to `mainDispatcher` for the `append` and + * the `resize` and does nothing to the emulator anywhere else. So a read and a mutation are two main-thread + * work items and **cannot interleave at all**. That is the same guarantee upstream Termux relies on + * (`TerminalSession$MainThreadHandler.handleMessage` calls `append`), and it is the only one available: + * `monitorenter` appears nowhere in `TerminalEmulator`, `TerminalBuffer`, `TerminalRow` or + * `TerminalRenderer`, so there is no lock to take. + * + * It is worth being explicit about why "read it anyway and let a torn read self-correct" is not on the + * table: that exact assumption was in this module's own KDoc until 2026-07-30 and it was wrong — a draw + * landing inside `TerminalEmulator.resize`'s window (new `mColumns` published at offsets 97-106, rows + * reallocated at offset 160) threw `ArrayIndexOutOfBoundsException` out of `TerminalRow.getStyle` and + * killed the process on a real emulator. `getSelectedText` indexes the same `mText`/`mLines` arrays through + * the same unsynchronised path, so a torn read here would be the same crash with the same lack of a + * self-correcting next frame. **Do not move emulator mutation off the main thread**, and do not call these + * methods from anywhere but the main thread. + * + * ### Why extraction is delegated rather than reimplemented + * `TerminalBuffer.getSelectedText(x1, y1, x2, y2)` is the one part of the stock selection stack that never + * touches `TerminalSession` — it walks rows via `TerminalRow.findStartOfColumn`, which is `WcWidth`-aware + * (so a wide CJK cell copies whole from either of its two columns), trims each line's trailing blanks, and + * joins soft-wrapped rows while preserving real line breaks. Re-deriving that in Kotlin would duplicate + * subtle logic that must agree with what the renderer draws. `TerminalSelectionBufferTest` pins the + * behaviour so a Termux bump that changes it fails loudly instead of silently copying the wrong bytes. + */ +internal class TerminalEmulatorSelectionBuffer( + private val emulator: () -> TerminalEmulator?, +) : TerminalSelectionBuffer { + + override fun bounds(): TerminalGridBounds? { + val emulator = emulator() ?: return null + return TerminalGridBounds( + columns = emulator.mColumns, + screenRows = emulator.mRows, + transcriptRows = emulator.screen.activeTranscriptRows, + ) + } + + override fun textIn(span: TerminalSelectionSpan): String { + val emulator = emulator() ?: return "" + val bounds = bounds() ?: return "" + // Clamping INSIDE the port is what makes the two unguarded stock failures unreachable for every + // caller: TerminalBuffer.externalToInternalRow throws IllegalArgumentException outside + // -activeTranscriptRows..screenRows, and TerminalRow.findStartOfColumn walks mText with no bound + // check for any column > mColumns. See TerminalSelectionSpan.clampedTo. + val clamped = span.clampedTo(bounds) ?: return "" + return try { + emulator.getSelectedText( + clamped.start.column, + clamped.start.row, + clamped.end.column, + clamped.end.row, + ) + } catch (e: IllegalArgumentException) { + // Should be unreachable after the clamp; a throw here would otherwise kill the process on the + // UI thread. Log the throwable TYPE only — never the message, which can carry row content. + logDroppedRead(e) + "" + } catch (e: IndexOutOfBoundsException) { + logDroppedRead(e) + "" + } + } + + private fun logDroppedRead(throwable: Throwable) { + Log.w(LOG_TAG, "dropping a selection read that threw: ${throwable.javaClass.simpleName}") + } + + private companion object { + private const val LOG_TAG: String = "TerminalSelection" + } +} + +/** + * The long-press snap: the run of non-blank cells around the pressed cell, so one press-and-release already + * selects something worth copying (a path, a hash, a container id) instead of a single character. + * + * ### Deliberately not stock's expansion + * `TextSelectionCursorController.setInitialTextSelectionPosition` (offsets 34-189) walks outward while the + * neighbouring cell's text `!= " "` — but a blank cell's `getSelectedText` is `""`, not `" "`, because + * trailing blanks are trimmed. So the stock comparison never matches and the expansion runs to both row + * edges: in Termux a long press selects the whole line. Testing for *blank* instead gives word-granular + * selection, which is what a shell line is actually made of; the user can still drag out to the rest of + * the line. + */ +internal object TerminalSelectionWords { + + /** + * The selection a long press at [cell] should start with, or null when there is no grid yet. + * + * The anchor is the START of the run and the focus its END, so a subsequent drag backwards past the + * word start flips the span the way a text editor does. + */ + fun runAt(cell: TerminalCell, buffer: TerminalSelectionBuffer): TerminalSelection? { + val bounds = buffer.bounds() ?: return null + if (!bounds.isMeasured) return null + val pressed = bounds.clamp(cell) + if (isBlank(pressed, buffer)) return TerminalSelection.at(pressed) + + var first = pressed.column + while (first > 0 && !isBlank(pressed.copy(column = first - 1), buffer)) first-- + + var last = pressed.column + while (last < bounds.lastColumn && !isBlank(pressed.copy(column = last + 1), buffer)) last++ + + return TerminalSelection.anchoredOn( + from = pressed.copy(column = first), + to = pressed.copy(column = last), + ) + } + + /** A cell is blank when its extracted text is empty (trailing-blank trimming) or whitespace. */ + private fun isBlank(cell: TerminalCell, buffer: TerminalSelectionBuffer): Boolean = + buffer.textIn(TerminalSelectionSpan(cell, cell)).isBlank() +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionController.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionController.kt new file mode 100644 index 0000000..b668f0e --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionController.kt @@ -0,0 +1,124 @@ +package wang.yaojia.webterm.terminalview + +/** What an explicit copy did. Public so `:app` can report the outcome in its own UI. */ +public enum class TerminalCopyResult { + /** The selected text is on the clipboard. */ + COPIED, + + /** Nothing was selected, so there was nothing to copy. */ + NOTHING_SELECTED, + + /** The selection resolved to blank cells; the clipboard was deliberately left alone. */ + NOTHING_TO_COPY, + + /** The platform clipboard refused the write (see [SystemTerminalClipboard]). */ + CLIPBOARD_UNAVAILABLE, +} + +/** + * The selection state machine: what a long press starts, what a drag does to it, what new output does to + * it, and the single path from a live selection to the clipboard. + * + * Pure of android and of Termux — the terminal arrives through [TerminalSelectionBuffer] and leaves through + * [TerminalClipboard], so every decision here is JVM-unit-testable. The view layer + * ([RemoteTerminalHostView]) owns only pixels→cells and the affordance. + * + * Not thread-safe by design: like [TerminalResizeDriver] it is driven from touch and draw callbacks, i.e. + * the main thread only. That is also the thread the emulator is mutated on, which is what makes the buffer + * reads safe (see [TerminalEmulatorSelectionBuffer]). + * + * @param onChanged fired with the new "is a selection active" value whenever it or the span changes. The + * view uses it to repaint the highlight and show/hide the Copy affordance. + */ +internal class TerminalSelectionController( + private val buffer: TerminalSelectionBuffer, + private val clipboard: TerminalClipboard, + private val onChanged: (isActive: Boolean) -> Unit, +) { + /** The live selection, or null when there is none. Immutable — every change replaces it. */ + var selection: TerminalSelection? = null + private set + + val isActive: Boolean get() = selection != null + + /** + * Start a selection at [cell], snapped to the word under it ([TerminalSelectionWords]). + * + * @return true when a selection now exists. False means the grid is not measured yet (nothing is bound, + * or the view has not been laid out), in which case the long press is simply ignored. + */ + fun beginAt(cell: TerminalCell): Boolean { + val started = TerminalSelectionWords.runAt(cell, buffer) ?: return false + publish(started) + return true + } + + /** Move the focus end to [cell] (clamped into the live grid). No-op without a selection. */ + fun extendTo(cell: TerminalCell) { + val current = selection ?: return + val bounds = buffer.bounds() ?: return + if (!bounds.isMeasured) return + publish(current.withFocus(bounds.clamp(cell))) + } + + /** + * Drop the selection. + * + * Reports nothing when there was no selection — load-bearing, because the Copy affordance is dismissed + * BY this method and its own dismissal callback calls it back; without the guard that is an infinite + * loop (see [RemoteTerminalHostView]'s action-mode handling). + */ + fun clear() { + if (selection == null) return + selection = null + onChanged(false) + } + + /** The text the current selection covers, or "" when there is none. Reads the buffer; mutates nothing. */ + fun selectedText(): String { + val span = selection?.span ?: return "" + return buffer.textIn(span) + } + + /** + * THE explicit copy — the only path from terminal content to the device clipboard (plan §8). + * + * A blank result is refused rather than written: replacing whatever the user had on their clipboard with + * an empty string is data loss, and it is the likely outcome of a stray long press on empty space. + */ + fun copy(): TerminalCopyResult { + if (selection == null) return TerminalCopyResult.NOTHING_SELECTED + val text = selectedText() + if (text.isBlank()) return TerminalCopyResult.NOTHING_TO_COPY + return if (clipboard.put(text)) TerminalCopyResult.COPIED else TerminalCopyResult.CLIPBOARD_UNAVAILABLE + } + + /** + * [scrolledRows] new rows scrolled off the top of the screen, so every external row index the selection + * holds is now that much smaller. + * + * Called from the screen-update path with `TerminalEmulator.getScrollCounter()` read BEFORE the stock + * `onScreenUpdated()` clears it — the stock view only maintains this for its own selection controller + * (`isSelectingText()`, always false here), so the shift has to be applied by us or the highlight ends + * up pointing at different text than the user selected. Once the top of the selection falls past the + * oldest retained transcript row the text is genuinely gone, and the selection is dropped rather than + * silently re-pointed — mirroring stock's `stopTextSelectionMode()` in the same situation. + */ + fun onContentScrolled(scrolledRows: Int) { + if (scrolledRows == 0) return + val current = selection ?: return + val bounds = buffer.bounds() ?: return + val moved = current.movedBy(-scrolledRows) + if (moved.span.start.row < bounds.firstRow) { + clear() + return + } + publish(moved) + } + + private fun publish(next: TerminalSelection) { + if (next == selection) return + selection = next + onChanged(true) + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometry.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometry.kt new file mode 100644 index 0000000..9b12011 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometry.kt @@ -0,0 +1,80 @@ +package wang.yaojia.webterm.terminalview + +import kotlin.math.floor + +/** One highlight band, in the host frame's own pixel space (which is the stock child's — it sits at 0,0). */ +internal data class TerminalSelectionRect( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, +) + +/** + * Pixels ↔ cells for the selection layer. Pure: the caller supplies the measured cell box and the + * viewport's `topRow`, so both directions are JVM-unit-testable without a device — the same trick + * [TerminalGridMath] plays for the resize formula. + * + * ### The mapping, and why it is not stock's + * This is the exact inverse of the stock accessors this module already reads: + * `TerminalView.getPointX(col)` is `round(col * fontWidth)` and `getPointY(row)` is + * `(row - topRow) * lineSpacing`, i.e. cell `(c, r)` owns the band + * `[c*fontWidth, (c+1)*fontWidth) x [(r-topRow)*lineSpacing, (r-topRow+1)*lineSpacing)`. `TerminalRenderer` + * draws each row's baseline inside that same band (`y += mFontLineSpacing` per row, starting at + * `mFontLineSpacingAndAscent`), so a highlight built this way lines up with the glyphs. + * + * Stock's own hit test ([com.termux.view.TerminalView.getColumnAndRow]) instead computes + * `(y - mFontLineSpacingAndAscent) / mFontLineSpacing`, which subtracts the (negative) font ascent and so + * lands roughly a fifth of a row above the glyph the user pressed. Reproducing that would make a long + * press select the line above itself near a row boundary, so it is deliberately not reproduced. + */ +internal object TerminalSelectionGeometry { + + /** + * The cell under a touch, or null when the renderer has not measured a cell box yet (before the first + * layout `fontWidth`/`lineSpacing` are 0 and there is no grid to divide by). + * + * The result is NOT clamped — a touch outside the grid legitimately resolves to an off-grid cell, and + * clamping happens once, against the live bounds, in [TerminalSelectionSpan.clampedTo]. + */ + fun cellAt(xPx: Float, yPx: Float, metrics: TerminalCellMetrics, topRow: Int): TerminalCell? { + if (metrics.fontWidthPx <= 0f || metrics.lineSpacingPx <= 0) return null + return TerminalCell( + column = floor(xPx / metrics.fontWidthPx).toInt(), + row = floor(yPx / metrics.lineSpacingPx).toInt() + topRow, + ) + } + + /** + * One band per VISIBLE row of [span]. + * + * Rows above or below the viewport are dropped rather than emitted with an off-screen `top`: a + * selection made in scrollback and then scrolled away must paint nothing, and a 10 000-row transcript + * selection must not build 10 000 rects for a 24-row screen. + */ + fun highlightRects( + span: TerminalSelectionSpan, + bounds: TerminalGridBounds, + metrics: TerminalCellMetrics, + topRow: Int, + ): List { + if (!bounds.isMeasured) return emptyList() + if (metrics.fontWidthPx <= 0f || metrics.lineSpacingPx <= 0) return emptyList() + + val firstVisibleRow = maxOf(span.start.row, topRow) + val lastVisibleRow = minOf(span.end.row, topRow + bounds.screenRows - 1) + if (firstVisibleRow > lastVisibleRow) return emptyList() + + return (firstVisibleRow..lastVisibleRow).mapNotNull { row -> + val columns = span.columnsIn(row, bounds.lastColumn) ?: return@mapNotNull null + val top = (row - topRow) * metrics.lineSpacingPx.toFloat() + TerminalSelectionRect( + left = columns.first * metrics.fontWidthPx, + top = top, + // Inclusive columns → the band covers the whole last cell, hence the +1. + right = (columns.last + 1) * metrics.fontWidthPx, + bottom = top + metrics.lineSpacingPx, + ) + } + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModel.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModel.kt new file mode 100644 index 0000000..0efb929 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModel.kt @@ -0,0 +1,169 @@ +package wang.yaojia.webterm.terminalview + +/** + * The first-party text-selection model — cell coordinates, normalisation and clamping. + * + * ### Why this exists at all (the stock selection UI is a crash, not a feature) + * `TerminalView.startTextSelectionMode` arms an `ActionMode` whose own handlers dereference the + * `TerminalSession` this app deliberately does not have: + * `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 are + * `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 are `mTermSession.onPasteTextFromClipboard()` + * (re-verified with `javap -p -c` on the pinned `terminal-view-v0.118.0.aar`). So making the stock UI + * reachable buys a selection whose **Copy button is an NPE**, which is strictly worse than no selection — + * the user loses their work to a process kill instead of finding the affordance absent. `mTermSession` + * cannot be supplied: `TerminalSession` is `final` and its constructor forks a local process, which is + * exactly what this app must not do (plan §6.1). + * + * The one thing in that stock stack that IS session-free is the extraction — + * `TerminalBuffer.getSelectedText(x1, y1, x2, y2)` reads rows and nothing else. So the replacement is: our + * own coordinates (this file), our own hit-testing and highlight ([TerminalSelectionGeometry]), stock + * extraction ([TerminalSelectionBuffer]) and a direct `ClipboardManager` write ([TerminalClipboard]) — + * `TextSelectionCursorController` bypassed entirely, `onLongPress` still consuming so the stock mode can + * never arm. + * + * Everything in this file is pure and immutable: no android, no Termux, no mutation. Every operation + * returns a new value. + */ + +/** + * One terminal cell, ordered **row-major** — which is what makes a drag up-and-right still a backwards + * drag, and lets [TerminalSelectionSpan] normalise with plain `minOf`/`maxOf`. + * + * @param column 0-based, left to right. + * @param row an **external** row in Termux's sense: `0 .. screenRows-1` is the live screen and negative + * rows walk back into the scrollback transcript. External rows are independent of the viewport's + * `topRow`, so scrolling the view never moves a selection — but new output does (see [movedBy]). + */ +internal data class TerminalCell(val column: Int, val row: Int) : Comparable { + + override fun compareTo(other: TerminalCell): Int = + if (row != other.row) row.compareTo(other.row) else column.compareTo(other.column) + + fun movedBy(rows: Int): TerminalCell = copy(row = row + rows) +} + +/** + * An **inclusive**, row-major-ordered cell range: [start] is never after [end]. + * + * That invariant is established by construction — every producer ([spanning], [clampedTo], [expandedTo]) + * orders its output — and it is what lets [columnsIn] and the buffer read treat start/end as first/last + * without re-checking. + */ +internal data class TerminalSelectionSpan(val start: TerminalCell, val end: TerminalCell) { + + val isSingleCell: Boolean get() = start == end + + /** The rows this span touches, oldest (smallest) first. */ + val rows: IntRange get() = start.row..end.row + + /** + * The columns selected on [row], or null when [row] is outside the span. + * + * The first row runs from its start column to the grid edge, the last row from column 0 to its end + * column, and every row between is whole — matching `TerminalBuffer.getSelectedText`'s own per-row + * range (offsets 57-98), so the highlight and the copied text can never disagree. + */ + fun columnsIn(row: Int, lastColumn: Int): IntRange? { + if (row < start.row || row > end.row) return null + val first = if (row == start.row) start.column else 0 + val last = if (row == end.row) end.column else lastColumn + return first..last + } + + /** This span grown to include [cell]. A cell already inside it changes nothing. */ + fun expandedTo(cell: TerminalCell): TerminalSelectionSpan = + TerminalSelectionSpan(minOf(start, cell), maxOf(end, cell)) + + fun movedBy(rows: Int): TerminalSelectionSpan = + TerminalSelectionSpan(start.movedBy(rows), end.movedBy(rows)) + + /** + * This span clamped into [bounds], or null when the grid has not been measured yet. + * + * **Load-bearing, not defensive dressing.** An unclamped cell reaches two unguarded stock paths: + * `TerminalBuffer.externalToInternalRow` THROWS `IllegalArgumentException` for a row outside + * `-activeTranscriptRows .. screenRows`, and `TerminalRow.findStartOfColumn` walks `mText` with **no + * bound check** for any `column > mColumns` (offsets 13-173 — only the exact `column == mColumns` case + * short-circuits), so an off-grid column runs off the end of the char array. A touch a few pixels past + * the last cell, or a drag that leaves the view, produces exactly those inputs. + */ + fun clampedTo(bounds: TerminalGridBounds): TerminalSelectionSpan? { + if (!bounds.isMeasured) return null + return TerminalSelectionSpan(bounds.clamp(start), bounds.clamp(end)) + } + + companion object { + /** The normalising factory: the two cells in either order produce the same span. */ + fun spanning(one: TerminalCell, other: TerminalCell): TerminalSelectionSpan = + TerminalSelectionSpan(minOf(one, other), maxOf(one, other)) + } +} + +/** + * A live selection as the user's gesture describes it. + * + * @param anchor what the long press claimed — a **span**, not a cell, because a press snaps to the word + * under it ([TerminalSelectionWords]). Keeping the whole anchor means a drag backwards past the word + * start still leaves that word selected, the way a text editor behaves, instead of collapsing it to the + * single cell the drag passed through. + * @param focus where the finger is now. + */ +internal data class TerminalSelection(val anchor: TerminalSelectionSpan, val focus: TerminalCell) { + + /** + * The normalised, inclusive span: the anchor grown to reach the focus. A backwards drag yields exactly + * the same span as the equivalent forwards one — [TerminalSelectionSpan.expandedTo] orders by + * construction, so there is no direction to get wrong. + */ + val span: TerminalSelectionSpan get() = anchor.expandedTo(focus) + + fun withFocus(cell: TerminalCell): TerminalSelection = copy(focus = cell) + + /** + * Shift the whole selection by [rows] (negative == the screen scrolled up under a live selection). + * + * New output renumbers external rows: the line the user selected is one row lower after each new line + * arrives. Without this shift the highlight would keep pointing at the same coordinates while the text + * under it changed — and the Copy button would then put *different* text on the clipboard than the one + * highlighted, which for terminal content (possibly secrets) is a correctness bug, not a cosmetic one. + */ + fun movedBy(rows: Int): TerminalSelection = + TerminalSelection(anchor.movedBy(rows), focus.movedBy(rows)) + + companion object { + /** A selection of one cell — the fallback when a press lands on blank space. */ + fun at(cell: TerminalCell): TerminalSelection = + TerminalSelection(TerminalSelectionSpan(cell, cell), cell) + + /** A selection anchored on a whole run of cells, focused at its end. */ + fun anchoredOn(from: TerminalCell, to: TerminalCell): TerminalSelection = + TerminalSelection(TerminalSelectionSpan.spanning(from, to), maxOf(from, to)) + } +} + +/** + * The live grid a selection is resolved against: the emulator's `mColumns` / `mRows` and its buffer's + * `activeTranscriptRows`. Read fresh for every operation (the grid changes on resize and the transcript + * grows with output), never cached. + */ +internal data class TerminalGridBounds( + val columns: Int, + val screenRows: Int, + val transcriptRows: Int, +) { + /** False before the first layout/bind, when there is no grid to clamp into. */ + val isMeasured: Boolean get() = columns > 0 && screenRows > 0 + + val lastColumn: Int get() = columns - 1 + + /** The oldest retained row; `getSelectedText` clamps to the same floor (offsets 15-29). */ + val firstRow: Int get() = -transcriptRows + + /** The newest row of the live screen; `getSelectedText` clamps to the same ceiling (offsets 30-46). */ + val lastRow: Int get() = screenRows - 1 + + fun clamp(cell: TerminalCell): TerminalCell = TerminalCell( + column = cell.column.coerceIn(0, lastColumn), + row = cell.row.coerceIn(firstRow, lastRow), + ) +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionPainter.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionPainter.kt new file mode 100644 index 0000000..781e1a9 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionPainter.kt @@ -0,0 +1,44 @@ +package wang.yaojia.webterm.terminalview + +import android.graphics.Canvas +import android.graphics.Paint + +/** + * Paints the selection highlight as an OVERLAY, on top of the stock glyphs. + * + * ### Why an overlay rather than the renderer's own selection channel + * `TerminalRenderer.render` already knows how to invert selected cells — `TerminalView.onDraw` passes it + * four selector ints and the renderer flags every column between them. That channel is + * `TerminalView.mDefaultSelectors`, which is **package-private** to `com.termux.view`, so reaching it would + * take either reflection (silently broken by a version bump, and the module already refused reflection for + * the cell metrics) or a source file planted in Termux's package. Neither is worth it for a highlight, and + * plan §6.1 keeps rendering stock: nothing here patches `TerminalRenderer` or `TerminalView.onDraw`. + * + * A translucent fill drawn after the children therefore does the job. It is drawn in the host frame's own + * coordinate space, which is the stock child's — the child fills the frame at (0,0) and `onDraw` applies no + * canvas translation, so the bands line up with the glyphs cell for cell. + */ +internal class TerminalSelectionPainter { + + private val fill = Paint().apply { + color = SELECTION_ARGB + style = Paint.Style.FILL + // Cell-aligned rectangles: smoothing their edges only blurs the boundary between two selected rows. + isAntiAlias = false + } + + fun draw(canvas: Canvas, rects: List) { + rects.forEach { rect -> canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, fill) } + } + + private companion object { + /** + * The design system's amber-gold (#E3A64A) at 35% alpha. + * + * Translucent because it is drawn OVER the glyphs: an opaque fill would hide the very text the user + * is about to copy. The literal is inline because `:terminal-view` has no dependency on `:app`'s + * design tokens (dependencies only flow down) — keep it in step with `WebTermColors` by hand. + */ + const val SELECTION_ARGB: Int = 0x59E3A64A.toInt() + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt new file mode 100644 index 0000000..e0239fa --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt @@ -0,0 +1,180 @@ +package wang.yaojia.webterm.terminalview + +import android.view.KeyEvent +import android.view.MotionEvent +import com.termux.terminal.TerminalSession +import com.termux.view.TerminalView +import com.termux.view.TerminalViewClient + +/** + * The `TerminalViewClient` that was never installed — the fix for the stock **key** paths only. + * + * `TerminalView.mClient` is dereferenced with **no null guard** on the first line of the paths a user + * actually hits (verified with `javap -p -c` on the pinned + * `terminal-view-v0.118.0.aar` → `TerminalView.class`): + * - `onCreateInputConnection` offset 0–4 → `mClient.isTerminalViewSelected()` — NPE the moment the soft + * keyboard attaches. + * - `onKeyDown` offset 82–92 → `mClient.onKeyDown(keyCode, event, mTermSession)` — NPE on the first key. + * - `onKeyUp` offset 64–70, `onKeyPreIme` offset 68–72, and the gesture callbacks — same shape. + * + * Installing any client removes the NPE; installing THIS one also keeps the crash from simply moving. + * A client that returned false would let the stock method continue into + * `mTermSession.write(getCharacters())` (offset 149–160), `handleKeyCode` → + * `mTermSession.getEmulator()` (offset 15–19, no guard) or `inputCodePoint` (which silently drops + * everything because of its own `mTermSession == null` early return at offset 59–66). `mTermSession` is + * null forever — `TerminalSession` is `final` and forking a process is exactly what this app must not do + * (plan §6.1). So this client **consumes the key itself** and returns true, which ends `onKeyDown` at + * `invalidate(); return true` (offset 97–105) before any of that is reachable. [TerminalKeyDecoder] + * encodes that as a type: it has no "hand it back to Termux" outcome. + * + * ### What this client does NOT cover (the correction that a review caught) + * A `TerminalViewClient` only sees the KEY paths. **Touch never passes through it**: `onTouchEvent` → + * `mGestureRecognizer` → `TerminalView$1.onScroll` → `doScroll` → `handleKeyCode` → + * `mTermSession.getEmulator()` has no client callback anywhere on it, so installing this client does + * nothing for the alternate-screen scroll crash. That one is fixed by ownership in + * [RemoteTerminalHostView], whose KDoc carries the full enumerated audit of which stock members are + * reachable and which are proven safe. Rendering and the cursor stay stock; scrolling and text selection + * do not (plan §6.1/§6.5 deviations, both recorded there and on [onLongPress]). + * + * Text selection is the one that changed shape rather than being cut: the long press is still consumed — + * the stock `ActionMode` can never arm — but it now starts the first-party selection layer + * ([TerminalSelectionController]) instead of being discarded. + * + * @param sink the single outlet — `:app`'s chord router, the emulator's live cursor modes, and the byte + * send pump. See [TerminalInputSink]. + */ +internal class WebTermTerminalViewClient(private val sink: TerminalInputSink) : TerminalViewClient { + + // ── Keys ───────────────────────────────────────────────────────────────────────────────────────── + + /** + * @param session ALWAYS null here (see the class doc) and therefore ignored — it is only in the + * signature because Termux passes its own `mTermSession` through. + */ + override fun onKeyDown(keyCode: Int, event: KeyEvent, session: TerminalSession?): Boolean { + val modes = sink.cursorModes() + val routing = TerminalKeyDecoder.decodeKeyDown( + keyCode = keyCode, + facts = KeyEventFacts.of(event), + appConsumed = sink.appConsumesKey(keyCode, event), + cursorKeysApplication = modes?.cursorKeysApplication ?: false, + keypadApplication = modes?.keypadApplication ?: false, + ) + return consume(routing) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean = + consume(TerminalKeyDecoder.decodeKeyUp(KeyEventFacts.of(event))) + + /** + * @param session ALWAYS null (unreachable in practice — `inputCodePoint` returns before calling us + * when `mTermSession` is null). Implemented anyway, returning true, so that even if a future edit + * made the path reachable it could still never fall through to `mTermSession.writeCodePoint`. + */ + override fun onCodePoint(codePoint: Int, ctrlDown: Boolean, session: TerminalSession?): Boolean { + TerminalInputBytes.encodeCodePoint(codePoint, ctrlDown, altDown = false)?.let(sink::sendInput) + return true + } + + private fun consume(routing: KeyRouting): Boolean = when (routing) { + is KeyRouting.Send -> { + sink.sendInput(routing.bytes) + true + } + + KeyRouting.Consumed -> true + // false → the stock method falls through to `super.onKeyDown/onKeyUp` (offsets 130 / 91), i.e. + // plain View handling. It never reaches the terminal path, because we also answer false to + // shouldBackButtonBeMappedToEscape below. + KeyRouting.DeferToHostView -> false + } + + /** + * MUST stay false. True would make `TerminalView.onKeyDown` route a system BACK press INTO the + * terminal branch (offset 113–136), whose next stop is `mTermSession.write(...)`. + */ + override fun shouldBackButtonBeMappedToEscape(): Boolean = false + + /** No latched modifier bar in this module — `:app`'s key-bar sends finished byte sequences instead. */ + override fun readControlKey(): Boolean = false + override fun readAltKey(): Boolean = false + override fun readShiftKey(): Boolean = false + override fun readFnKey(): Boolean = false + + /** Termux's Ctrl+Space quirk workaround is for its own extra-keys bar; not applicable. */ + override fun shouldUseCtrlSpaceWorkaround(): Boolean = false + + // ── IME hints ──────────────────────────────────────────────────────────────────────────────────── + + /** + * Only consulted by the stock `onCreateInputConnection`, which [RemoteTerminalHostView] overrides — + * but it must answer true so that if anything ever does reach the stock path it configures a text + * editor rather than declaring the view unselected. + */ + override fun isTerminalViewSelected(): Boolean = true + + /** Terminals want no autocorrect, no suggestions and no learned-word history of what you typed. */ + override fun shouldEnforceCharBasedInput(): Boolean = true + + // ── Gestures ───────────────────────────────────────────────────────────────────────────────────── + + /** Tapping the terminal takes focus and raises the soft keyboard (Termux's own app does the same). */ + override fun onSingleTapUp(event: MotionEvent?) = sink.onTapped() + + /** + * Pinch-zoom is declined: the font size is what the grid maths divides by, so changing it mid-flight + * would need a resize round-trip on every pinch frame. Returning the identity scale pins + * [TerminalView]'s `mScaleFactor` at 1 and leaves the metrics stable. + */ + override fun onScale(scale: Float): Float = NO_SCALE + + /** + * Start a FIRST-PARTY text selection at the press, and consume the event. + * + * **The return value MUST stay true.** `TerminalView$1.onLongPress` offsets 14-30 are + * `if (mClient.onLongPress(e)) return;`, so true is the one supported way to stop + * `startTextSelectionMode` from arming — and it has to be stopped, because the stock selection + * `ActionMode` is a crash rather than a feature without a `TerminalSession`: + * `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 (**Copy**) are + * `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 (**Paste**) are + * `mTermSession.onPasteTextFromClipboard()` — both unguarded, both on the only two buttons the mode + * exists for. Selecting text would work; the moment the user tapped Copy the process would die. + * `mTermSession` cannot be supplied either: `TerminalSession` is `final`, its constructor builds a + * process-bound `MainThreadHandler`, and having no local subprocess is the point (plan §6.1). + * + * Copy-out is therefore **no longer a feature gap**: the press is spent on the replacement layer + * instead of being discarded. [TerminalSelection] carries the design — our own cell coordinates + * and highlight, stock's session-free `TerminalBuffer.getSelectedText` for the extraction, and a direct + * `ClipboardManager` write ([TerminalClipboard]), with `TextSelectionCursorController` bypassed + * entirely. The affordance is our own floating `ActionMode` ([TerminalSelectionActionMode]), which is + * what plan §6.5 asks for; only the stock controller is replaced. Paste is untouched — it already works + * through the IME ([RemoteTerminalInputConnection]). + */ + override fun onLongPress(event: MotionEvent?): Boolean { + event?.let { sink.onLongPressAt(it.x, it.y) } + return true + } + + override fun copyModeChanged(copyMode: Boolean) = Unit + + /** Only fired from the stock `updateSize`, which is dead for us; our binding path is explicit. */ + override fun onEmulatorSet() = Unit + + // ── Logging: intentionally inert ───────────────────────────────────────────────────────────────── + // Termux funnels key codes, typed characters and IME text through these. Terminal content and + // keystrokes are exactly what must never reach logcat (plan §8, "untrusted server strings" / no + // secret leakage), so every one of them is a no-op — matching NoOpTerminalSessionClient. + + 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 + + private companion object { + /** The identity scale factor — declines pinch-zoom without disturbing the renderer. */ + const val NO_SCALE: Float = 1.0f + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/EmulatorMutationThreadTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/EmulatorMutationThreadTest.kt new file mode 100644 index 0000000..9937591 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/EmulatorMutationThreadTest.kt @@ -0,0 +1,239 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.TerminalEmulator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +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.wire.ClientMessage +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +/** + * REGRESSION for the fourth 2026-07-30 crash: a regrid on the confined writer thread tearing the + * `TerminalBuffer` out from under the stock renderer's UI-thread read. + * + * ## What was observed on a device (AVD `webterm`, android-35) + * ``` + * FATAL EXCEPTION: main + * java.lang.ArrayIndexOutOfBoundsException: length=31; index=31 + * at com.termux.terminal.TerminalRow.getStyle(TerminalRow.java:244) + * at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:104) + * at com.termux.view.TerminalView.onDraw(TerminalView.java:921) + * ``` + * + * ## Why `length == index` is a signature, not a coincidence + * `TerminalRenderer.render` caches its column bound ONCE, from `TerminalEmulator.mColumns` + * (`terminal-view-v0.118.0.aar`, `render` offsets 14-18), then walks `0 until columns` calling + * `TerminalRow.getStyle(column)` (offset 346) — a raw `long[]` index whose array was sized to the + * `TerminalBuffer.mColumns` in force when the row was allocated (`TerminalRow.` offsets 21-25: + * `mStyle = new long[columns]`). `TerminalEmulator.resize` publishes its own `mColumns` FIRST (offsets + * 97-106) and only then calls `resizeScreen()` → `TerminalBuffer.resize` (offset 160), which is where the + * rows are actually reallocated. So a reader that starts between those two points sees the NEW bound + * against OLD rows, and the very first out-of-range column is exactly `oldColumns` — hence + * `index == length`, every time, for any grow. + * + * ## Why these two tests can live on the JVM + * `TerminalEmulator`/`TerminalBuffer`/`TerminalRow` are pure Java. [renderLikeRead] reproduces the + * renderer's *indexing contract* — cache the bound, then index rows — so the race is reachable without a + * `Canvas`, a `View` or a device. `TerminalResizeDrawRaceRegressionTest` (instrumented) covers the same + * defect through the real `onDraw`; this pair is the fast gate that keeps it from coming back. + */ +class EmulatorMutationThreadTest { + + /** + * The crash itself: hammer alternating grids through [RemoteTerminalSession.updateSize] while a + * reader on the *render* thread walks the buffer the way `TerminalRenderer.render` does. + * + * Before the fix this throws within a handful of iterations (`AIOOBE length=31; index=31` from + * `getStyle`, or `IllegalArgumentException extRow=…, mScreenRows=…` from `externalToInternalRow` — + * the same regrid observed at a different offset). After it, the mutation and the read are both main + * -thread work items and can no longer interleave at all. + * + * The reader deliberately does NOT touch `TerminalRow.mText`: that array is reallocated by + * `setChar` (offsets 283-320 / 430-471) so it has its own, much rarer, tearing shape which would make + * this test flaky for a reason that is not the regrid. This test is about the regrid. + */ + @Test + fun `a regrid never tears the buffer out from under a reader on the render thread`() { + val renderThread = Executors.newSingleThreadExecutor { r -> Thread(r, RENDER_THREAD) } + val writerThread = Executors.newSingleThreadExecutor { r -> Thread(r, WRITER_THREAD) } + val resizesOnTheWire = AtomicInteger(0) + val session = RemoteTerminalSession( + engineSend = { if (it is ClientMessage.Resize) resizesOnTheWire.incrementAndGet() }, + transcriptRows = TEST_TRANSCRIPT_ROWS, + appendDispatcher = writerThread.asCoroutineDispatcher(), + mainDispatcher = renderThread.asCoroutineDispatcher(), + ) + session.bind {} + + val failure = AtomicReference(null) + val readsCompleted = AtomicInteger(0) + val stop = AtomicBoolean(false) + val reader = readerOnRenderThread(renderThread, session.emulator, stop, failure, readsCompleted) + + try { + repeat(REGRID_ITERATIONS) { iteration -> + val cols = if (iteration % 2 == 0) NARROW_COLS else WIDE_COLS + session.updateSize(cols = cols, rows = ROWS) + session.feedRemote(SAMPLE_OUTPUT) + } + awaitTrue("all $REGRID_ITERATIONS regrids to reach the wire") { + resizesOnTheWire.get() >= REGRID_ITERATIONS || failure.get() != null + } + } finally { + stop.set(true) + runBlocking { reader.join() } + session.close() + renderThread.shutdownNow() + writerThread.shutdownNow() + } + + assertNull( + failure.get(), + "the render thread saw a half-applied regrid — TerminalBuffer was mutated off the render " + + "thread (${failure.get()})", + ) + assertTrue( + readsCompleted.get() > 0, + "the reader never ran, so this test proved nothing (completed=${readsCompleted.get()})", + ) + assertEquals( + REGRID_ITERATIONS, + resizesOnTheWire.get(), + "every real grid change must still reach the server — the fix must not disable resize", + ) + } + + /** + * The invariant behind the fix, pinned positively: the emulator is mutated on the RENDER thread. + * + * `TerminalEmulator.append` calls `TerminalOutput.titleChanged` synchronously while parsing an + * `OSC 0 ; … BEL`, so the title delegate is a free probe for "which thread is inside `append`". If a + * future edit moves the append back onto the confined writer, this fails immediately and by name, + * rather than as a one-in-N crash on somebody's phone. + */ + @Test + fun `emulator mutation runs on the render thread, not the confined writer`() { + val renderThread = Executors.newSingleThreadExecutor { r -> Thread(r, RENDER_THREAD) } + val writerThread = Executors.newSingleThreadExecutor { r -> Thread(r, WRITER_THREAD) } + val threadInsideAppend = AtomicReference(null) + val session = RemoteTerminalSession( + engineSend = {}, + onTitleChanged = { threadInsideAppend.set(hostThreadName()) }, + appendDispatcher = writerThread.asCoroutineDispatcher(), + mainDispatcher = renderThread.asCoroutineDispatcher(), + ) + session.bind {} + + try { + session.feedRemote(TITLE_SEQUENCE) + awaitTrue("the OSC title to be parsed") { threadInsideAppend.get() != null } + } finally { + session.close() + renderThread.shutdownNow() + writerThread.shutdownNow() + } + + assertEquals( + RENDER_THREAD, + threadInsideAppend.get(), + "TerminalEmulator.append must run on the same thread that draws it (stock TerminalRenderer " + + "reads TerminalBuffer with no synchronisation of any kind — `monitorenter` appears " + + "nowhere in the pinned emulator or renderer)", + ) + } + + // ── Helpers ────────────────────────────────────────────────────────────────────────────────────── + + /** + * `TerminalRenderer.render`'s read contract, reduced to the indexing that can throw: cache the + * column/row bounds from the emulator (offsets 6-18), resolve each screen row through + * `externalToInternalRow` + `allocateFullLineIfNecessary` (offsets 173-185), then index the row's + * style array per column (offset 346). + */ + private fun renderLikeRead(emulator: TerminalEmulator) { + val columns = emulator.mColumns + val rows = emulator.mRows + val screen = emulator.screen + for (row in 0 until rows) { + val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)) + for (column in 0 until columns) { + line.getStyle(column) + } + } + } + + private fun readerOnRenderThread( + renderThread: java.util.concurrent.ExecutorService, + emulator: TerminalEmulator, + stop: AtomicBoolean, + failure: AtomicReference, + readsCompleted: AtomicInteger, + ): Job = CoroutineScope(renderThread.asCoroutineDispatcher()).launch { + while (!stop.get()) { + try { + renderLikeRead(emulator) + readsCompleted.incrementAndGet() + } catch (t: Throwable) { + failure.compareAndSet(null, t) + return@launch + } + // Single-threaded dispatcher: yielding is what lets a queued mutation run at all. Without it + // this loop would monopolise the render thread and the test would pass vacuously. + yield() + } + } + + /** + * The executor thread's own name, with the coroutines debug-agent's ` @coroutine#N` suffix stripped — + * that suffix is appended per-continuation, so the raw name is not a stable identity. + */ + private fun hostThreadName(): String = Thread.currentThread().name.substringBefore(COROUTINE_SUFFIX) + + private fun awaitTrue(what: String, predicate: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(AWAIT_TIMEOUT_SECONDS) + while (System.nanoTime() < deadline) { + if (predicate()) return + Thread.sleep(POLL_INTERVAL_MS) + } + assertTrue(predicate(), "timed out after $AWAIT_TIMEOUT_SECONDS s waiting for: $what") + } + + private companion object { + const val RENDER_THREAD = "test-render-thread" + const val WRITER_THREAD = "test-writer-thread" + + /** What `kotlinx-coroutines-debug` appends to a thread name while a continuation runs on it. */ + const val COROUTINE_SUFFIX = " @coroutine#" + + /** The observed crash was `length=31; index=31`; alternating 31 ↔ 64 reproduces it verbatim. */ + const val NARROW_COLS = 31 + const val WIDE_COLS = 64 + const val ROWS = 24 + + /** Deep enough that a reflow takes real time (the race window), shallow enough to stay fast. */ + const val TEST_TRANSCRIPT_ROWS = 4_000 + + /** The confined-thread version threw within a couple of iterations; 40 leaves no room for luck. */ + const val REGRID_ITERATIONS = 40 + + const val AWAIT_TIMEOUT_SECONDS = 30L + const val POLL_INTERVAL_MS = 5L + + /** Plain glyphs plus a newline, so the reflow has content to wrap rather than an empty grid. */ + val SAMPLE_OUTPUT: ByteArray = ("web terminal regrid probe ".repeat(8) + "\r\n").toByteArray() + + /** `OSC 0 ; title BEL` — parsed inside `append`, which is what makes the title a thread probe. */ + val TITLE_SEQUENCE: ByteArray = "\u001b]0;probe\u0007".toByteArray() + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostViewTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostViewTest.kt new file mode 100644 index 0000000..4d29b6e --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostViewTest.kt @@ -0,0 +1,207 @@ +package wang.yaojia.webterm.terminalview + +import android.view.View +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +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.BeforeEach +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_HEIGHT_PX +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_APPLICATION_CURSOR_KEYS +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_MOUSE_TRACKING +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.down +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.mouseWheel +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.move +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.up +import com.termux.terminal.TerminalEmulator + +/** + * The host frame's ownership of scrolling and autofill — the two stock paths that would otherwise NPE + * (see [StockTerminalViewHazardTest] for the hazard itself, executed). + * + * The touch calls here mirror exactly what `ViewGroup.dispatchTouchEvent` does: `onInterceptTouchEvent` + * is consulted for every event while a child holds the touch target; the instant it answers **true** the + * child is sent `ACTION_CANCEL`, the target is dropped, and every later event of the gesture goes to the + * frame's own `onTouchEvent`. So "intercept returned true" is precisely the statement "the stock + * `TerminalView` will never see the rest of this drag" — which is what keeps `doScroll` unreachable. + */ +class RemoteTerminalHostViewTest { + + private lateinit var host: RemoteTerminalHostView + private lateinit var sink: RecordingInputSink + + @BeforeEach + fun setUp() { + // The renderer measures its cell box in the constructor, so this must precede the view. + StockTerminalViewFixture.mockCellMetrics() + host = RemoteTerminalHostView(StockTerminalViewFixture.context()) + sink = RecordingInputSink() + host.sink = sink + } + + @AfterEach fun tearDown() = unmockkAll() + + private fun bind(vararg setupSequences: String): TerminalEmulator { + val emulator = StockTerminalViewFixture.emulator() + setupSequences.forEach { emulator.feed(it) } + host.bindEmulator(emulator) + return emulator + } + + /** Drag the finger UP by [pixels] — the direction that scrolls toward newer output. */ + private fun dragUp(pixels: Int) { + assertFalse(host.onInterceptTouchEvent(down(START_Y)), "a touch-down alone is not a scroll") + // The claim consumes the slop travel, exactly like GestureDetector. + assertTrue(host.onInterceptTouchEvent(move(START_Y - CLAIM_TRAVEL_PX)), "the drag must be claimed") + host.onTouchEvent(move(START_Y - CLAIM_TRAVEL_PX - pixels)) + host.onTouchEvent(up(START_Y - CLAIM_TRAVEL_PX - pixels)) + } + + // ── Finding 1: the alternate-screen scroll crash ───────────────────────────────────────────────── + + @Test + fun `a swipe inside an alternate-screen TUI emits arrow keys instead of reaching the stock scroll`() { + bind(ENTER_ALTERNATE_BUFFER) + + dragUp(pixels = 2 * CELL_HEIGHT_PX) + + // Stock would have run doScroll -> handleKeyCode -> mTermSession.getEmulator() -> NPE. + assertEquals(listOf("\u001b[B", "\u001b[B"), sink.sent, "two cells of travel == two DOWN arrows") + } + + @Test + fun `the arrows follow the emulator's live DECCKM mode`() { + bind(ENTER_ALTERNATE_BUFFER, ENABLE_APPLICATION_CURSOR_KEYS) + + dragUp(pixels = CELL_HEIGHT_PX) + + assertEquals(listOf("\u001bOB"), sink.sent, "vim/htop in application-cursor mode need SS3") + } + + @Test + fun `dragging the other way inside an alternate-screen TUI emits the opposite arrow`() { + bind(ENTER_ALTERNATE_BUFFER) + + assertFalse(host.onInterceptTouchEvent(down(START_Y))) + assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX))) + host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + CELL_HEIGHT_PX)) + + assertEquals(listOf("\u001b[A"), sink.sent) + } + + @Test + fun `an external mouse wheel inside an alternate-screen TUI also stays off the stock scroll path`() { + bind(ENTER_ALTERNATE_BUFFER) + + // Stock onGenericMotionEvent offsets 43-55 hand a +-3 row scroll straight to doScroll. + assertTrue(host.dispatchGenericMotionEvent(mouseWheel(scrollUp = true))) + + assertEquals(List(MOUSE_WHEEL_ROWS) { "\u001b[A" }, sink.sent) + } + + @Test + fun `a two-finger gesture is left to the stock pinch detector`() { + bind(ENTER_ALTERNATE_BUFFER) + + assertFalse(host.onInterceptTouchEvent(down(START_Y))) + assertFalse( + host.onInterceptTouchEvent(move(START_Y - CLAIM_TRAVEL_PX, pointers = 2)), + "claiming a pinch would kill zoom handling for a scroll we are not doing", + ) + assertTrue(sink.sent.isEmpty()) + } + + // ── The two non-crashing stock branches must still behave like stock ───────────────────────────── + + @Test + fun `on the normal buffer the drag scrolls the local transcript and sends nothing`() { + val emulator = bind() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + + // Drag DOWN — the direction that walks back into scrollback. + assertFalse(host.onInterceptTouchEvent(down(START_Y))) + assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX))) + host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + 3 * CELL_HEIGHT_PX)) + + assertEquals(-3, host.stockView.topRow, "three cells of travel == three transcript rows") + assertTrue(sink.sent.isEmpty(), "scrollback is local; the shell must not see arrow keys") + } + + @Test + fun `transcript scrolling stops at the ends of the buffer`() { + val emulator = bind() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + val transcriptRows = emulator.screen.activeTranscriptRows + + assertFalse(host.onInterceptTouchEvent(down(START_Y))) + assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX))) + host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + OVERSCROLL_CELLS * CELL_HEIGHT_PX)) + + assertEquals(-transcriptRows, host.stockView.topRow, "cannot scroll past the oldest retained row") + } + + @Test + fun `a mouse-reporting TUI still gets wheel buttons, not arrows`() { + val emulator = bind(ENTER_ALTERNATE_BUFFER, ENABLE_MOUSE_TRACKING) + assertTrue(emulator.isMouseTrackingActive) + + dragUp(pixels = CELL_HEIGHT_PX) + + // Stock doScroll offsets 26-53 prefer the mouse report over both other branches; the emulator + // turns it into the wire bytes itself, so nothing reaches the key sink. + assertTrue(sink.sent.isEmpty(), "a mouse-tracking program reads wheel reports, not cursor keys") + } + + @Test + fun `a drag shorter than the touch slop is left to the stock view`() { + bind(ENTER_ALTERNATE_BUFFER) + + assertFalse(host.onInterceptTouchEvent(down(START_Y))) + assertFalse( + host.onInterceptTouchEvent(move(START_Y - 1f)), + "a one-pixel wobble is a tap; stealing it would break tap-to-focus", + ) + assertTrue(sink.sent.isEmpty()) + } + + @Test + fun `nothing is emitted before an emulator is bound`() { + dragUp(pixels = 4 * CELL_HEIGHT_PX) + + assertTrue(sink.sent.isEmpty()) + } + + // ── Finding 2: autofill ───────────────────────────────────────────────────────────────────────── + + @Test + fun `the terminal subtree is excluded from the autofill structure`() { + // TerminalView.autofill() writes straight into a null mTermSession (StockTerminalViewHazardTest), + // and it advertises itself as fillable, so the only fix is to keep it out of the structure the + // autofill service walks. `View.isImportantForAutofill()` walks the parent chain calling + // getImportantForAutofill() and stops at the first *_EXCLUDE_DESCENDANTS, so the frame answering + // this covers the stock child — which is why the frame OVERRIDES the getter rather than only + // setting the flag (a setter can be undone; an override cannot). + assertEquals(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS, host.importantForAutofill) + } + + private companion object { + /** Where a drag starts, far enough from 0 that a downward drag stays positive. */ + const val START_Y: Float = 400f + + /** Comfortably past any plausible touch slop, so the claim is unambiguous. */ + const val CLAIM_TRAVEL_PX: Float = 100f + + /** Stock `onGenericMotionEvent` scrolls three rows per wheel notch. */ + const val MOUSE_WHEEL_ROWS: Int = 3 + + /** Enough output to push rows off the top so there is a transcript to scroll into. */ + const val SCROLLED_OFF_LINES: Int = 40 + + /** More cells than the transcript holds, to prove the clamp. */ + const val OVERSCROLL_CELLS: Int = 500 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSelectionTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSelectionTest.kt new file mode 100644 index 0000000..9c25eef --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSelectionTest.kt @@ -0,0 +1,319 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.TerminalEmulator +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +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.BeforeEach +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_HEIGHT_PX +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_WIDTH_PX +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.down +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.move +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.up + +/** + * Copy-out, driven through the real host frame and the real `TerminalViewClient` — the first-party + * replacement for the stock selection UI that cannot be made reachable (its Copy button dereferences the + * absent `TerminalSession`; see [TerminalSelection]'s doc and [WebTermTerminalViewClient.onLongPress]). + * + * The gesture entry point is called directly because Termux's `GestureAndScaleRecognizer` is framework code, + * stripped by the mockable `android.jar`. Everything after it — the client's decision, the pixel→cell + * mapping, the touch routing, the state machine, the buffer read — is the real compiled path. + */ +class RemoteTerminalSelectionTest { + + private lateinit var host: RemoteTerminalHostView + private lateinit var client: WebTermTerminalViewClient + private lateinit var clipboard: RecordingClipboard + private lateinit var sink: SelectionRoutingSink + + /** The same forwarding [RemoteTerminalView] installs: long press and tap belong to the frame. */ + private class SelectionRoutingSink(private val host: () -> RemoteTerminalHostView) : TerminalInputSink { + val sent = mutableListOf() + var keyboardRequests = 0 + + override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false + override fun cursorModes(): TerminalCursorModes? = null + override fun sendInput(data: String) { + sent += data + } + + override fun onTapped() { + if (host().isSelectingText) host().clearSelection() else keyboardRequests++ + } + + override fun onLongPressAt(xPx: Float, yPx: Float) { + host().beginSelectionAt(xPx, yPx) + } + + override fun onViewSize(widthPx: Int, heightPx: Int) = Unit + } + + private class RecordingClipboard : TerminalClipboard { + val puts = mutableListOf() + + override fun put(text: String): Boolean { + puts += text + return true + } + } + + @BeforeEach + fun setUp() { + StockTerminalViewFixture.mockCellMetrics() + clipboard = RecordingClipboard() + host = RemoteTerminalHostView(StockTerminalViewFixture.context(), clipboard) + sink = SelectionRoutingSink { host } + host.sink = sink + client = WebTermTerminalViewClient(sink) + host.installClient(client) + } + + @AfterEach fun tearDown() = unmockkAll() + + private fun bind(vararg output: String): TerminalEmulator { + val emulator = StockTerminalViewFixture.emulator() + output.forEach { emulator.feed(it) } + host.bindEmulator(emulator) + return emulator + } + + /** Pixel centre of a cell, using the fixture's pinned cell box. */ + private fun x(column: Int) = (column + 0.5f) * CELL_WIDTH_PX + private fun y(row: Int) = (row + 0.5f) * CELL_HEIGHT_PX + + private fun longPress(column: Int, row: Int) { + assertTrue( + client.onLongPress(down(y = y(row), x = x(column))), + "the long press must stay CONSUMED so stock text selection can never arm", + ) + } + + // ── The constraint: the stock selection mode stays unreachable ─────────────────────────────────── + + @Test + fun `a long press starts a first-party selection and never arms the stock ActionMode`() { + bind("cat /etc/hosts") + + longPress(column = 6, row = 0) + + assertTrue(host.isSelectingText, "the first-party layer owns the selection") + assertFalse( + host.stockView.isSelectingText, + "TextSelectionCursorController's Copy is mTermSession.onCopyTextToClipboard — an NPE", + ) + } + + @Test + fun `a long press before an emulator is bound is simply ignored`() { + longPress(column = 3, row = 0) + + assertFalse(host.isSelectingText) + } + + // ── Selecting, extending, copying ─────────────────────────────────────────────────────────────── + + @Test + fun `the long press snaps to the word under the finger and copies exactly that`() { + bind("cat /etc/hosts") + longPress(column = 6, row = 0) + + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + + assertEquals(listOf("/etc/hosts"), clipboard.puts) + } + + @Test + fun `dragging after the long press extends the selection instead of scrolling`() { + // The same vertical drag with no selection is a transcript scroll / arrow-key emit; with a live + // selection it must extend, or the feature is unusable on a touch screen. + bind("alpha\r\nbravo\r\ncharlie\r\n") + longPress(column = 0, row = 0) + + assertTrue(host.onInterceptTouchEvent(move(y = y(2), x = x(2))), "the extend drag is claimed") + host.onTouchEvent(up(y = y(2), x = x(2))) + + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + assertEquals(listOf("alpha\nbravo\ncha"), clipboard.puts) + } + + @Test + fun `an extend drag inside an alternate-screen TUI sends no arrow keys`() { + // Without the selection branch this drag would go to TerminalScrollGesture and type into the TUI. + bind(ENTER_ALTERNATE_BUFFER, "vim buffer") + longPress(column = 0, row = 0) + + host.onInterceptTouchEvent(move(y = y(3), x = x(5))) + host.onTouchEvent(up(y = y(3), x = x(5))) + + assertTrue(sink.sent.isEmpty(), "extending a selection must not reach the shell") + } + + @Test + fun `a drag backwards past the anchor still selects forwards`() { + bind("alpha\r\nbravo\r\ncharlie\r\n") + longPress(column = 2, row = 2) // snaps to "charlie", columns 0-6 + + host.onInterceptTouchEvent(move(y = y(0), x = x(2))) + host.onTouchEvent(up(y = y(0), x = x(2))) + + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + assertEquals(listOf("pha\nbravo\ncharlie"), clipboard.puts) + } + + @Test + fun `a second finger during an extend is not read as an extend`() { + bind("alpha\r\nbravo\r\ncharlie\r\n") + longPress(column = 0, row = 0) + val before = host.copySelection().also { clipboard.puts.clear() } + + host.onInterceptTouchEvent(move(y = y(2), x = x(2), pointers = 2)) + + assertEquals(TerminalCopyResult.COPIED, before) + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + assertEquals(listOf("alpha"), clipboard.puts, "the pinch left the selection alone") + } + + // ── Dismissal ─────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a tap dismisses the selection instead of popping the keyboard`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + client.onSingleTapUp(down(y = y(0), x = x(0))) + + assertFalse(host.isSelectingText) + assertEquals(0, sink.keyboardRequests, "the tap is spent on the dismissal") + } + + @Test + fun `a tap with no selection still raises the keyboard`() { + bind("cat /etc/hosts") + + client.onSingleTapUp(down(y = y(0), x = x(0))) + + assertEquals(1, sink.keyboardRequests) + } + + @Test + fun `after a dismissal a drag scrolls again`() { + val emulator = bind() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + longPress(column = 0, row = 0) + client.onSingleTapUp(down(y = y(0), x = x(0))) + + assertFalse(host.onInterceptTouchEvent(down(START_Y))) + assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX))) + host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + 2 * CELL_HEIGHT_PX)) + + assertEquals(-2, host.stockView.topRow) + } + + @Test + fun `binding a new emulator drops a stale selection`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + bind("a different session") + + assertFalse(host.isSelectingText, "the old span points into a transcript that no longer exists") + } + + @Test + fun `a layout change drops the selection`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + // A new size means a regrid and a reflow: the cells the span names are about to hold other text. + host.onSizeChanged(VIEW_WIDTH_PX, VIEW_HEIGHT_PX, 0, 0) + + assertFalse(host.isSelectingText) + } + + // ── Live output under a selection ─────────────────────────────────────────────────────────────── + + @Test + fun `new output shifts the selection and pins the viewport so the highlight stays on its text`() { + val emulator = bind("target\r\n") + // Overfills the screen on purpose: those rows scroll BEFORE the press, so the emulator's scroll + // counter is already non-zero when the selection is made. Counting them would shift the span by + // rows it never saw — which is why beginSelectionAt zeroes the counter. + repeat(SCREEN_ROWS) { emulator.feed("filler\r\n") } + longPress(column = 1, row = 0) + val selectedBefore = host.selectedText() + + repeat(SCROLL_ROWS) { emulator.feed("more\r\n") } + host.requestScreenUpdate() + + assertEquals(selectedBefore, host.selectedText(), "the same TEXT, at a new row index") + assertEquals( + -SCROLL_ROWS, + host.stockView.topRow, + "stock onScreenUpdated jumps to the newest row unless we pin it back", + ) + } + + @Test + fun `output with no selection still jumps to the newest row`() { + val emulator = bind() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + host.stockView.topRow = -5 + + host.requestScreenUpdate() + + assertEquals(0, host.stockView.topRow, "the pin is for selections only") + } + + @Test + fun `a selection scrolled out of the transcript is dropped`() { + val emulator = StockTerminalViewFixture.emulator(transcriptRows = SMALL_TRANSCRIPT) + host.bindEmulator(emulator) + emulator.feed("target\r\n") + longPress(column = 1, row = 0) + + repeat(SMALL_TRANSCRIPT + SCREEN_ROWS + 1) { emulator.feed("flood\r\n") } + host.requestScreenUpdate() + + assertFalse(host.isSelectingText) + } + + // ── Highlight geometry ────────────────────────────────────────────────────────────────────────── + + @Test + fun `the highlight covers the selected cells`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + val rects = host.highlightRects() + + assertEquals(1, rects.size) + assertEquals(0f, rects[0].left) + assertEquals(3 * CELL_WIDTH_PX, rects[0].right, "'cat' is columns 0-2") + assertEquals(0f, rects[0].top) + assertEquals(CELL_HEIGHT_PX.toFloat(), rects[0].bottom) + } + + @Test + fun `there is no highlight without a selection`() { + bind("cat /etc/hosts") + + assertTrue(host.highlightRects().isEmpty()) + } + + private companion object { + const val SCREEN_ROWS: Int = 24 + const val SCROLLED_OFF_LINES: Int = 40 + const val SCROLL_ROWS: Int = 3 + const val SMALL_TRANSCRIPT: Int = 100 + const val START_Y: Float = 400f + const val CLAIM_TRAVEL_PX: Float = 100f + const val VIEW_WIDTH_PX: Int = 720 + const val VIEW_HEIGHT_PX: Int = 1280 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionResizeTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionResizeTest.kt new file mode 100644 index 0000000..bb023c8 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionResizeTest.kt @@ -0,0 +1,162 @@ +package wang.yaojia.webterm.terminalview + +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 +import wang.yaojia.webterm.wire.ClientMessage + +/** + * BLOCKER 2, the session half: a resize is an emulator MUTATION, so §6.2's single-WRITER model requires + * it to travel the same confined `Channel` as the output feed — never a direct call + * from the layout thread. These tests pin both the wire behaviour (one `Resize` per real change) and the + * ordering (a resize submitted before a feed is applied to the buffer before that feed). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class RemoteTerminalSessionResizeTest { + + private fun sentResizes(sent: List) = sent.filterIsInstance() + + @Test + fun `updateSize reflows the emulator and emits one Resize frame`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val sent = mutableListOf() + val session = RemoteTerminalSession( + engineSend = { sent += it }, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + session.bind {} + + session.updateSize(cols = 100, rows = 40) + advanceUntilIdle() + + assertEquals(listOf(ClientMessage.Resize(100, 40)), sentResizes(sent)) + assertEquals(100, session.emulator.mColumns, "the local buffer must reflow to match the server") + assertEquals(40, session.emulator.mRows) + + session.close() + advanceUntilIdle() + } + + @Test + fun `an identical resize is skipped so the socket is not spammed`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val sent = mutableListOf() + val session = RemoteTerminalSession( + engineSend = { sent += it }, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + session.bind {} + + session.updateSize(100, 40) + session.updateSize(100, 40) + session.updateSize(100, 40) + advanceUntilIdle() + + assertEquals(1, sentResizes(sent).size) + assertEquals(TerminalGridSize(100, 40), session.lastSentDimsForTest()) + + session.close() + advanceUntilIdle() + } + + @Test + fun `a non-positive dimension never reaches the wire`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val sent = mutableListOf() + val session = RemoteTerminalSession( + engineSend = { sent += it }, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + session.bind {} + + session.updateSize(0, 40) + session.updateSize(80, 0) + session.updateSize(-5, -5) + advanceUntilIdle() + + assertTrue(sentResizes(sent).isEmpty(), "server validates 1..1000; never send a bogus grid") + + session.close() + advanceUntilIdle() + } + + @Test + fun `a resize submitted before a feed is applied to the buffer first`() = runTest { + // The ordering guarantee of the confined writer: if the resize were applied off-channel (or + // after), these 7 characters would lay out against the OLD 80-column grid and the wrap point + // would be wrong — exactly the class of bug the single-writer model exists to prevent. + val dispatcher = StandardTestDispatcher(testScheduler) + val session = RemoteTerminalSession( + engineSend = {}, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + session.bind {} + + session.updateSize(cols = 5, rows = 24) + session.feedRemote("ABCDEFG".toByteArray(Charsets.UTF_8)) + advanceUntilIdle() + + assertEquals(5, session.emulator.mColumns) + val text = session.screenTextForTest() + assertTrue(text.contains("ABCDE"), "expected a wrap at column 5; got: '${text.trim()}'") + assertTrue(text.contains("FG"), "the overflow must land on the next row; got: '${text.trim()}'") + + session.close() + advanceUntilIdle() + } + + @Test + fun `a resize repaints the view because the local buffer reflowed`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val session = RemoteTerminalSession( + engineSend = {}, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + var updates = 0 + session.bind { updates++ } + advanceUntilIdle() + val afterBind = updates + + session.updateSize(100, 40) + advanceUntilIdle() + + assertTrue(updates > afterBind, "a reflow that is never repainted leaves a stale screen") + + session.close() + advanceUntilIdle() + } + + @Test + fun `a resize before bind still reaches the wire and is applied in order`() = runTest { + // The first layout pass can beat the AndroidView bind; the server must still learn the real grid. + val dispatcher = StandardTestDispatcher(testScheduler) + val sent = mutableListOf() + val session = RemoteTerminalSession( + engineSend = { sent += it }, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + + session.updateSize(5, 24) + session.feedRemote("ABCDEFG".toByteArray(Charsets.UTF_8)) + advanceUntilIdle() + assertEquals(listOf(ClientMessage.Resize(5, 24)), sentResizes(sent)) + + session.bind {} + advanceUntilIdle() + + assertTrue(session.screenTextForTest().contains("ABCDE")) + + session.close() + advanceUntilIdle() + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt new file mode 100644 index 0000000..04f0bc8 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt @@ -0,0 +1,143 @@ +package wang.yaojia.webterm.terminalview + +import android.content.Context +import android.content.res.Resources +import android.graphics.Paint +import android.util.DisplayMetrics +import android.view.InputDevice +import android.view.MotionEvent +import android.view.accessibility.AccessibilityManager +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TerminalOutput +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor + +/** + * Headless fixtures for driving the **real** Termux `TerminalView` / [RemoteTerminalHostView] on the JVM. + * + * The AGP mockable `android.jar` (`isReturnDefaultValues = true`) strips every framework method body, so + * the views construct and their own bytecode runs for real — which is exactly what these tests need: the + * stock crash paths and our guards are the real compiled code, not a re-description of it. Two framework + * pieces do need help: + * - `Context.getSystemService("accessibility")` — the stock `TerminalView` constructor calls + * `isEnabled()` on the result, so a default `null` would NPE before the view exists. + * - `Paint` — `TerminalRenderer` measures its cell box through it, and default `0f` metrics would make + * every cell zero-height. [mockCellMetrics] pins a [CELL_WIDTH_PX]×[CELL_HEIGHT_PX] cell so the + * px→row arithmetic is exercised with real numbers. + */ +internal object StockTerminalViewFixture { + + /** The pinned cell box these tests measure scroll distances against. */ + const val CELL_WIDTH_PX: Float = 12f + const val CELL_HEIGHT_PX: Int = 24 + + /** Termux's `TerminalRenderer` derives line spacing from `Paint.getFontSpacing()`. */ + private const val FONT_ASCENT_PX: Float = -18f + + /** A context complete enough for `TerminalView`'s constructor and our sp→px text sizing. */ + fun context(): Context { + val resources = mockk(relaxed = true) + every { resources.displayMetrics } returns DisplayMetrics() + val context = mockk(relaxed = true) + every { context.resources } returns resources + every { context.getSystemService("accessibility") } returns mockk(relaxed = true) + return context + } + + /** Must be called BEFORE building a view — the renderer measures its cell in the constructor. */ + fun mockCellMetrics() { + mockkConstructor(Paint::class) + every { anyConstructed().fontSpacing } returns CELL_HEIGHT_PX.toFloat() + every { anyConstructed().ascent() } returns FONT_ASCENT_PX + every { anyConstructed().measureText(any()) } returns CELL_WIDTH_PX + } + + /** A real emulator with no subprocess — the same shape `RemoteTerminalSession` binds. */ + fun emulator(cols: Int = 80, rows: Int = 24, transcriptRows: Int = 1_000): TerminalEmulator = + TerminalEmulator(SilentTerminalOutput(), cols, rows, transcriptRows, NoOpTerminalSessionClient()) + + /** Feed raw bytes the way the WS output path does. */ + fun TerminalEmulator.feed(text: String) { + val bytes = text.toByteArray() + append(bytes, bytes.size) + } + + /** `ESC[?1049h` — the alternate screen buffer every full-screen TUI (vim/htop/less/man) enters. */ + const val ENTER_ALTERNATE_BUFFER: String = "\u001b[?1049h" + + /** `ESC[?1h` — DECCKM application-cursor mode, which switches arrows from CSI to SS3. */ + const val ENABLE_APPLICATION_CURSOR_KEYS: String = "\u001b[?1h" + + /** `ESC[?1000h` — X11 mouse reporting, the branch stock `doScroll` prefers over everything else. */ + const val ENABLE_MOUSE_TRACKING: String = "\u001b[?1000h" + + fun down(y: Float, x: Float = 0f): MotionEvent = touch(MotionEvent.ACTION_DOWN, x, y) + fun move(y: Float, x: Float = 0f, pointers: Int = 1): MotionEvent = + touch(MotionEvent.ACTION_MOVE, x, y, pointers) + + fun up(y: Float, x: Float = 0f): MotionEvent = touch(MotionEvent.ACTION_UP, x, y) + + private fun touch(action: Int, x: Float, y: Float, pointers: Int = 1): MotionEvent { + val event = mockk(relaxed = true) + every { event.action } returns action + every { event.actionMasked } returns action + every { event.x } returns x + every { event.y } returns y + every { event.pointerCount } returns pointers + every { event.downTime } returns TOUCH_DOWN_TIME + every { event.isFromSource(any()) } returns false + return event + } + + /** An external-mouse wheel notch — the second stock entry into `doScroll`. */ + fun mouseWheel(scrollUp: Boolean): MotionEvent { + val event = mockk(relaxed = true) + every { event.action } returns MotionEvent.ACTION_SCROLL + every { event.actionMasked } returns MotionEvent.ACTION_SCROLL + every { event.isFromSource(InputDevice.SOURCE_MOUSE) } returns true + every { event.getAxisValue(MotionEvent.AXIS_VSCROLL) } returns if (scrollUp) 1f else -1f + every { event.x } returns 0f + every { event.y } returns 0f + every { event.downTime } returns TOUCH_DOWN_TIME + return event + } + + /** One gesture == one down-time, which is what stock `sendMouseEventCode` anchors the wheel cell on. */ + private const val TOUCH_DOWN_TIME: Long = 1_000L + + private class SilentTerminalOutput : 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 + } +} + +/** Captures everything the host view routes out, so a test can assert exact bytes. */ +internal class RecordingInputSink : TerminalInputSink { + val sent = mutableListOf() + var taps = 0 + val longPresses = mutableListOf>() + val sizes = mutableListOf>() + + override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false + override fun cursorModes(): TerminalCursorModes? = null + override fun sendInput(data: String) { + sent += data + } + + override fun onTapped() { + taps++ + } + + override fun onLongPressAt(xPx: Float, yPx: Float) { + longPresses += xPx to yPx + } + + override fun onViewSize(widthPx: Int, heightPx: Int) { + sizes += widthPx to heightPx + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewHazardTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewHazardTest.kt new file mode 100644 index 0000000..732db7a --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewHazardTest.kt @@ -0,0 +1,74 @@ +package wang.yaojia.webterm.terminalview + +import android.view.KeyEvent +import android.view.autofill.AutofillValue +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed +import com.termux.view.TerminalView + +/** + * The two stock `TerminalView` methods that dereference `mTermSession` with **no null guard**, executed + * for real so the hazard is a measured fact rather than a bytecode reading. + * + * `mTermSession` is null forever in this app: `TerminalSession` is `final`, its constructor forks a local + * process over JNI, and having no local process is the whole point (plan §6.1). A dummy session does not + * help either — `getEmulator()` would return null and NPE on the very next instruction. + * + * These tests do not change behaviour; they LOCK the reason [RemoteTerminalHostView] must keep both paths + * unreachable. If a future Termux bump adds the missing guards, they fail — which is the signal to + * re-audit and simplify, not to delete them. + */ +class StockTerminalViewHazardTest { + + @AfterEach fun tearDown() = unmockkAll() + + /** + * `doScroll` offsets 56-79: while the ALTERNATE screen buffer is active it turns a scroll into + * `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)`, whose offsets 15-19 are `mTermSession.getEmulator()` + * with no guard. `mEmulator` IS guarded at offset 4; `mTermSession` is not — that asymmetry is the + * whole bug. Any swipe inside vim/htop/less/man/tmux takes this path. + */ + @Test + fun `stock scrolling on an alternate-screen buffer dereferences the absent TerminalSession`() { + StockTerminalViewFixture.mockCellMetrics() + val view = TerminalView(StockTerminalViewFixture.context(), null) + view.setTextSize(TEXT_SIZE_PX) + val emulator = StockTerminalViewFixture.emulator() + emulator.feed(ENTER_ALTERNATE_BUFFER) + view.mEmulator = emulator + + assertTrue(emulator.isAlternateBufferActive, "the fixture must reproduce the crashing state") + assertThrows(NullPointerException::class.java) { + view.handleKeyCode(KeyEvent.KEYCODE_DPAD_UP, NO_KEY_MODIFIERS) + } + } + + /** + * `autofill(AutofillValue)` offsets 7-20: `mTermSession.write(value.getTextValue().toString())`, again + * unguarded — and the view advertises itself as fillable (`getAutofillType()` = AUTOFILL_TYPE_TEXT, + * `getAutofillValue()` non-null), so any autofill service may call it. [RemoteTerminalHostView] + * answers by excluding the whole subtree from the autofill structure. + */ + @Test + fun `stock autofill dereferences the absent TerminalSession`() { + StockTerminalViewFixture.mockCellMetrics() + val view = TerminalView(StockTerminalViewFixture.context(), null) + val filled = mockk() + every { filled.isText } returns true + every { filled.textValue } returns "a-password-manager-suggestion" + + assertThrows(NullPointerException::class.java) { view.autofill(filled) } + } + + private companion object { + const val NO_KEY_MODIFIERS: Int = 0 + const val TEXT_SIZE_PX: Int = 12 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytesTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytesTest.kt new file mode 100644 index 0000000..67fee22 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytesTest.kt @@ -0,0 +1,130 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.KeyHandler +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * [TerminalInputBytes] is the byte-for-byte re-derivation of the two Termux paths our client has to + * take over, because both of them dead-end on the `TerminalSession` we do not have: + * + * - `TerminalView.inputCodePoint(codePoint, ctrl, alt)` — the control-key + dead-key transform + * (verified against `terminal-view-v0.118.0.aar` → `TerminalView.class` offsets 148–374), which then + * calls `mTermSession.writeCodePoint(...)`. `inputCodePoint` early-returns at offset 59–66 when + * `mTermSession == null`, so with our forked session the whole IME text path is silently DROPPED. + * - `TerminalSession.writeCodePoint(prependEscape, codePoint)` (offsets 0–125) — an optional `ESC` + * prefix for Alt, UTF-8 encoding, and an `IllegalArgumentException` for an invalid code point (we + * return null instead — a hostile/garbled key must never throw into the key path). + * + * These assertions are the regression lock: if the transform drifts, Ctrl-C stops interrupting Claude. + */ +class TerminalInputBytesTest { + + @Test + fun `control transform maps letters to their C0 codes`() { + // Ctrl-C is THE key of this app (interrupt Claude): 'c' (99) -> 0x03. + assertEquals("\u0003", TerminalInputBytes.encodeCodePoint('c'.code, ctrlDown = true, altDown = false)) + assertEquals("\u0003", TerminalInputBytes.encodeCodePoint('C'.code, ctrlDown = true, altDown = false)) + assertEquals("\u0001", TerminalInputBytes.encodeCodePoint('a'.code, ctrlDown = true, altDown = false)) + assertEquals("\u001a", TerminalInputBytes.encodeCodePoint('z'.code, ctrlDown = true, altDown = false)) + } + + @Test + fun `control transform maps the punctuation and digit aliases`() { + // Offsets 197-318 of TerminalView.inputCodePoint, alias-for-alias. + assertEquals("\u0000", TerminalInputBytes.encodeCodePoint(' '.code, ctrlDown = true, altDown = false)) + assertEquals("\u0000", TerminalInputBytes.encodeCodePoint('2'.code, ctrlDown = true, altDown = false)) + assertEquals("\u001b", TerminalInputBytes.encodeCodePoint('['.code, ctrlDown = true, altDown = false)) + assertEquals("\u001b", TerminalInputBytes.encodeCodePoint('3'.code, ctrlDown = true, altDown = false)) + assertEquals("\u001c", TerminalInputBytes.encodeCodePoint('\\'.code, ctrlDown = true, altDown = false)) + assertEquals("\u001d", TerminalInputBytes.encodeCodePoint(']'.code, ctrlDown = true, altDown = false)) + assertEquals("\u001e", TerminalInputBytes.encodeCodePoint('^'.code, ctrlDown = true, altDown = false)) + assertEquals("\u001f", TerminalInputBytes.encodeCodePoint('_'.code, ctrlDown = true, altDown = false)) + assertEquals("\u001f", TerminalInputBytes.encodeCodePoint('/'.code, ctrlDown = true, altDown = false)) + assertEquals("\u007f", TerminalInputBytes.encodeCodePoint('8'.code, ctrlDown = true, altDown = false)) + } + + @Test + fun `an unmapped code point is unchanged by the control transform`() { + assertEquals("!", TerminalInputBytes.encodeCodePoint('!'.code, ctrlDown = true, altDown = false)) + } + + @Test + fun `alt prepends ESC, matching writeCodePoint's prependEscape`() { + assertEquals("\u001bx", TerminalInputBytes.encodeCodePoint('x'.code, ctrlDown = false, altDown = true)) + // Alt+Ctrl composes: ESC then the C0 code. + assertEquals("\u001b\u0003", TerminalInputBytes.encodeCodePoint('c'.code, ctrlDown = true, altDown = true)) + } + + @Test + fun `dead-key aliases map to their ASCII equivalents after the control transform`() { + // TerminalView.inputCodePoint offsets 319-374: 710 -> '^', 715 -> '`', 732 -> '~'. + assertEquals("^", TerminalInputBytes.encodeCodePoint(710, ctrlDown = false, altDown = false)) + assertEquals("`", TerminalInputBytes.encodeCodePoint(715, ctrlDown = false, altDown = false)) + assertEquals("~", TerminalInputBytes.encodeCodePoint(732, ctrlDown = false, altDown = false)) + } + + @Test + fun `an invalid code point yields null instead of throwing`() { + // TerminalSession.writeCodePoint THROWS IllegalArgumentException here (offsets 0-44). A garbled + // key event must never take down the key path, so we drop it. + assertNull(TerminalInputBytes.encodeCodePoint(0x110000, ctrlDown = false, altDown = false)) + assertNull(TerminalInputBytes.encodeCodePoint(0xD800, ctrlDown = false, altDown = false)) + assertNull(TerminalInputBytes.encodeCodePoint(0xDFFF, ctrlDown = false, altDown = false)) + assertNull(TerminalInputBytes.encodeCodePoint(-1, ctrlDown = false, altDown = false)) + } + + @Test + fun `a non-BMP code point round-trips as a surrogate pair`() { + val emoji = 0x1F600 + assertEquals(String(Character.toChars(emoji)), TerminalInputBytes.encodeCodePoint(emoji, false, false)) + } + + @Test + fun `encodeText maps LF to CR and preserves everything else`() { + // The repo-wide gotcha: Enter is \r (0x0D), never \n. Termux's sendTextToTerminal does the same + // remap (TerminalView$2.sendTextToTerminal offsets 113-122) before inputCodePoint. + assertEquals("ls -la\r", TerminalInputBytes.encodeText("ls -la\n")) + assertEquals("hi\r", TerminalInputBytes.encodeText("hi\r")) + assertEquals("", TerminalInputBytes.encodeText("")) + } + + @Test + fun `encodeText preserves CJK and surrogate pairs verbatim`() { + // Invariant #9/#1: input bytes pass through unfiltered — an IME commit of CJK or an emoji must + // arrive at the shell byte-identical. + assertEquals("你好", TerminalInputBytes.encodeText("你好")) + val emoji = String(Character.toChars(0x1F600)) + assertEquals(emoji, TerminalInputBytes.encodeText(emoji)) + } + + @Test + fun `keyModifiers builds the Termux KEYMOD bitset`() { + assertEquals(0, TerminalInputBytes.keyModifiers(false, false, false, false)) + assertEquals(KeyHandler.KEYMOD_CTRL, TerminalInputBytes.keyModifiers(true, false, false, false)) + assertEquals(KeyHandler.KEYMOD_ALT, TerminalInputBytes.keyModifiers(false, true, false, false)) + assertEquals(KeyHandler.KEYMOD_SHIFT, TerminalInputBytes.keyModifiers(false, false, true, false)) + assertEquals(KeyHandler.KEYMOD_NUM_LOCK, TerminalInputBytes.keyModifiers(false, false, false, true)) + assertEquals( + KeyHandler.KEYMOD_CTRL or KeyHandler.KEYMOD_SHIFT, + TerminalInputBytes.keyModifiers(ctrl = true, alt = false, shift = true, numLock = false), + ) + } + + @Test + fun `effectiveMetaState clears the ctrl bits and re-adds shift`() { + // TerminalView.onKeyDown offsets 357-418: bitsToClear = META_CTRL_MASK (+ the ALT bits unless + // NumLock), then shift re-adds META_SHIFT_ON|META_SHIFT_LEFT_ON (65). + val ctrlAndAlt = TerminalInputBytes.META_CTRL_MASK or TerminalInputBytes.META_ALT_BITS + assertEquals(0, TerminalInputBytes.effectiveMetaState(ctrlAndAlt, shift = false, numLockOn = false)) + assertEquals( + TerminalInputBytes.META_ALT_BITS, + TerminalInputBytes.effectiveMetaState(ctrlAndAlt, shift = false, numLockOn = true), + ) + assertEquals( + TerminalInputBytes.META_SHIFT_BITS, + TerminalInputBytes.effectiveMetaState(ctrlAndAlt, shift = true, numLockOn = false), + ) + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoderTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoderTest.kt new file mode 100644 index 0000000..1d9c401 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoderTest.kt @@ -0,0 +1,193 @@ +package wang.yaojia.webterm.terminalview + +import android.view.KeyEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * BLOCKER 1 — the decision table that keeps every stock Termux path that dereferences `mTermSession` + * UNREACHABLE. + * + * Verified against `terminal-view-v0.118.0.aar` → `TerminalView.class`: + * - `onKeyDown` offset 82–92 calls `mClient.onKeyDown(keyCode, event, mTermSession)` with **no null + * guard on `mClient`** → today's guaranteed NPE on the first key press. Offsets 97–105 show the only + * escape hatch: a client that returns **true** ends the method at `invalidate(); return true`. + * - Every path past that point touches the session we do not have: offset 149–160 + * `mTermSession.write(event.getCharacters())` (ACTION_MULTIPLE), offset 326 → `handleKeyCode` whose + * offsets 15–19 do `mTermSession.getEmulator()` with no guard, and offset 558 → `inputCodePoint` + * which dead-ends at its own `mTermSession == null` early return (offset 59–66). + * + * So [KeyRouting] deliberately has **no variant that hands the key back to the stock terminal path**: + * the only non-consuming outcome is [KeyRouting.DeferToHostView], which we implement as + * `FrameLayout.super.onKeyDown` — the Activity's BACK/volume handling, never Termux's. + */ +class TerminalKeyDecoderTest { + + /** Facts with everything off — the shape of a plain, unmodified key press. */ + private fun facts( + isSystemKey: Boolean = false, + ctrl: Boolean = false, + alt: Boolean = false, + shift: Boolean = false, + numLock: Boolean = false, + function: Boolean = false, + metaState: Int = 0, + unicodeChar: (Int) -> Int = { 0 }, + ) = KeyEventFacts(isSystemKey, ctrl, alt, shift, numLock, function, metaState, unicodeChar) + + // ── The mTermSession-unreachability invariant ──────────────────────────────────────────────────── + + @Test + fun `KeyRouting has no branch that defers to the stock terminal path`() { + // A structural lock on the design constraint. The `when` below is EXHAUSTIVE over the sealed + // hierarchy, so adding a fourth outcome — notably a "let Termux handle it" one, which would put + // mTermSession back on the reachable path — fails compilation here and cannot land unnoticed. + val outcomes = listOf(KeyRouting.Send("x"), KeyRouting.Consumed, KeyRouting.DeferToHostView) + val labels = outcomes.map { routing -> + when (routing) { + is KeyRouting.Send -> "bytes onto the wire" + KeyRouting.Consumed -> "swallowed" + KeyRouting.DeferToHostView -> "host view super (Activity BACK/volume)" + } + } + assertEquals(3, labels.toSet().size, "the three outcomes must stay distinct") + } + + @Test + fun `an unmapped, unprintable key is consumed rather than handed back to Termux`() { + // e.g. a bare CTRL_LEFT: KeyHandler has no code and getUnicodeChar is 0. Stock would return false + // and fall through toward mTermSession; we consume instead. + val routing = TerminalKeyDecoder.decodeKeyDown( + keyCode = KeyEvent.KEYCODE_CTRL_LEFT, + facts = facts(), + appConsumed = false, + cursorKeysApplication = false, + keypadApplication = false, + ) + assertEquals(KeyRouting.Consumed, routing) + } + + // ── DECCKM (plan §6.3) — the authority stays Termux's KeyHandler ───────────────────────────────── + + @Test + fun `arrows resolve through KeyHandler and differ between normal and application-cursor mode`() { + val normal = TerminalKeyDecoder.decodeKeyDown( + KeyEvent.KEYCODE_DPAD_UP, facts(), appConsumed = false, + cursorKeysApplication = false, keypadApplication = false, + ) + val application = TerminalKeyDecoder.decodeKeyDown( + KeyEvent.KEYCODE_DPAD_UP, facts(), appConsumed = false, + cursorKeysApplication = true, keypadApplication = false, + ) + assertEquals(KeyRouting.Send("\u001b[A"), normal, "DECCKM off must emit CSI") + assertEquals(KeyRouting.Send("\u001bOA"), application, "DECCKM on must emit SS3, never a hardcoded CSI") + assertTrue(normal != application, "the two cursor modes must not collapse to one byte string") + } + + @Test + fun `Enter emits CR and Tab emits HT, shift-Tab emits CSI Z`() { + assertEquals( + KeyRouting.Send("\r"), + TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_ENTER, facts(), false, false, false), + "Enter must send \\r (0x0D), never \\n", + ) + assertEquals( + KeyRouting.Send("\t"), + TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_TAB, facts(), false, false, false), + ) + assertEquals( + KeyRouting.Send("\u001b[Z"), + TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_TAB, facts(shift = true), false, false, false), + ) + } + + @Test + fun `Backspace emits DEL`() { + assertEquals( + KeyRouting.Send("\u007f"), + TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_DEL, facts(), false, false, false), + ) + } + + // ── The app outlet (:app's HardwareKeyRouter via RemoteTerminalView.onKeyCommand) ──────────────── + + @Test + fun `an app-consumed key emits nothing more but is still consumed`() { + // :app already sent the bytes through onKeyCommand; emitting again would double-type. + val routing = TerminalKeyDecoder.decodeKeyDown( + KeyEvent.KEYCODE_ESCAPE, facts(), appConsumed = true, + cursorKeysApplication = false, keypadApplication = false, + ) + assertEquals(KeyRouting.Consumed, routing) + } + + // ── System keys keep working (BACK must still leave the screen) ────────────────────────────────── + + @Test + fun `a system key defers to the host view so BACK still navigates`() { + val routing = TerminalKeyDecoder.decodeKeyDown( + KeyEvent.KEYCODE_BACK, facts(isSystemKey = true), appConsumed = false, + cursorKeysApplication = false, keypadApplication = false, + ) + assertEquals(KeyRouting.DeferToHostView, routing) + } + + @Test + fun `key-up defers only for system keys and consumes everything else`() { + assertEquals(KeyRouting.DeferToHostView, TerminalKeyDecoder.decodeKeyUp(facts(isSystemKey = true))) + assertEquals(KeyRouting.Consumed, TerminalKeyDecoder.decodeKeyUp(facts())) + } + + // ── Printable keys: the unicode path replaces the dead inputCodePoint path ─────────────────────── + + @Test + fun `a printable key with no KeyHandler mapping sends its unicode char`() { + val routing = TerminalKeyDecoder.decodeKeyDown( + KeyEvent.KEYCODE_A, facts(unicodeChar = { 'a'.code }), appConsumed = false, + cursorKeysApplication = false, keypadApplication = false, + ) + assertEquals(KeyRouting.Send("a"), routing) + } + + @Test + fun `Ctrl-C resolves to the interrupt byte`() { + val routing = TerminalKeyDecoder.decodeKeyDown( + KeyEvent.KEYCODE_C, facts(ctrl = true, unicodeChar = { 'c'.code }), appConsumed = false, + cursorKeysApplication = false, keypadApplication = false, + ) + assertEquals(KeyRouting.Send("\u0003"), routing) + } + + @Test + fun `Alt-x resolves to ESC + x`() { + val routing = TerminalKeyDecoder.decodeKeyDown( + KeyEvent.KEYCODE_X, facts(alt = true, unicodeChar = { 'x'.code }), appConsumed = false, + cursorKeysApplication = false, keypadApplication = false, + ) + assertEquals(KeyRouting.Send("\u001bx"), routing) + } + + @Test + fun `a dead-key combining accent is swallowed rather than emitting garbage`() { + // getUnicodeChar sets KeyCharacterMap.COMBINING_ACCENT (0x80000000) for a dead key. Termux runs a + // mCombiningAccent state machine on a package-private view field we cannot reach; we consume the + // key instead so no garbled byte reaches the shell (composition is device-QA / IME-path work). + val routing = TerminalKeyDecoder.decodeKeyDown( + KeyEvent.KEYCODE_GRAVE, + facts(unicodeChar = { TerminalInputBytes.COMBINING_ACCENT or '`'.code }), + appConsumed = false, cursorKeysApplication = false, keypadApplication = false, + ) + assertEquals(KeyRouting.Consumed, routing) + } + + @Test + fun `a function-pressed key skips the KeyHandler table like Termux does`() { + // TerminalView.onKeyDown offset 319-333 gates handleKeyCode on !event.isFunctionPressed(). + val routing = TerminalKeyDecoder.decodeKeyDown( + KeyEvent.KEYCODE_DPAD_UP, facts(function = true, unicodeChar = { 0 }), appConsumed = false, + cursorKeysApplication = false, keypadApplication = false, + ) + assertEquals(KeyRouting.Consumed, routing, "Fn+Up must not emit the plain arrow sequence") + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriverTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriverTest.kt new file mode 100644 index 0000000..1d088c3 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriverTest.kt @@ -0,0 +1,183 @@ +package wang.yaojia.webterm.terminalview + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * BLOCKER 2 — nothing ever drove a `resize`, so the server PTY stayed at its 80×24 attach default + * forever and every full-screen TUI (Claude's own UI, vim, htop) rendered into the wrong box. + * + * The stock fallback is dead by construction: `TerminalView.updateSize()` (verified in + * `terminal-view-v0.118.0.aar`, offsets 18–25) early-returns when `mTermSession == null`, which is + * permanently our case. [TerminalResizeDriver] is the replacement: it turns a real layout size + the + * renderer's real cell metrics into a grid via the already-tested pure [TerminalGridMath], and pushes it + * at the session (which routes it through the confined writer — see [RemoteTerminalSessionResizeTest]). + */ +class TerminalResizeDriverTest { + + /** Metrics in the shape `TerminalRenderer` reports them: a fractional advance width, an int line box. */ + private val metrics = TerminalCellMetrics(fontWidthPx = 10f, lineSpacingPx = 20) + + /** One push at the driver's outlet: the grid, and whether it was flagged as a forced re-assert. */ + private data class Push(val grid: TerminalGridSize, val isReassert: Boolean) + + private class Recorder { + val pushes = mutableListOf() + val grids: List get() = pushes.map { it.grid } + fun apply(grid: TerminalGridSize, isReassert: Boolean) { + pushes += Push(grid, isReassert) + } + } + + private fun driver(recorder: Recorder, metrics: () -> TerminalCellMetrics? = { this.metrics }) = + TerminalResizeDriver(cellMetrics = metrics, applyGrid = recorder::apply) + + @Test + fun `a laid-out size resolves to the expected grid`() { + val recorder = Recorder() + driver(recorder).onViewSize(widthPx = 800, heightPx = 600, horizontalPaddingPx = 0, verticalPaddingPx = 0) + + assertEquals(listOf(TerminalGridSize(cols = 80, rows = 30)), recorder.grids) + } + + @Test + fun `padding is subtracted on both edges`() { + val recorder = Recorder() + driver(recorder).onViewSize(800, 600, horizontalPaddingPx = 15, verticalPaddingPx = 10) + + // (800 - 30)/10 = 77 cols · (600 - 20)/20 = 29 rows + assertEquals(listOf(TerminalGridSize(cols = 77, rows = 29)), recorder.grids) + } + + @Test + fun `a repeated identical size is skipped so rotation does not spam the socket`() { + val recorder = Recorder() + val driver = driver(recorder) + + driver.onViewSize(800, 600, 0, 0) + driver.onViewSize(800, 600, 0, 0) + driver.onViewSize(800, 600, 0, 0) + + assertEquals(1, recorder.grids.size, "an unchanged grid must not be pushed again") + } + + @Test + fun `a different pixel size that resolves to the same grid is also skipped`() { + val recorder = Recorder() + val driver = driver(recorder) + + driver.onViewSize(800, 600, 0, 0) // 80x30 + driver.onViewSize(805, 605, 0, 0) // still 80x30 — sub-cell growth, no new grid + + assertEquals(1, recorder.grids.size, "dedup is on the GRID, not on the pixel size") + } + + @Test + fun `a genuinely new grid is pushed`() { + val recorder = Recorder() + val driver = driver(recorder) + + driver.onViewSize(800, 600, 0, 0) + driver.onViewSize(600, 800, 0, 0) // rotation + + assertEquals( + listOf(TerminalGridSize(80, 30), TerminalGridSize(60, 40)), + recorder.grids, + ) + } + + @Test + fun `a pre-layout size is dropped`() { + val recorder = Recorder() + val driver = driver(recorder) + + driver.onViewSize(0, 0, 0, 0) + driver.onViewSize(800, 0, 0, 0) + driver.onViewSize(0, 600, 0, 0) + + assertTrue(recorder.grids.isEmpty(), "a 0-dimension view has no valid grid to send") + } + + @Test + fun `no metrics yet means no resize`() { + // The renderer only exists after setTextSize; before that there is nothing to divide by. + val recorder = Recorder() + driver(recorder, metrics = { null }).onViewSize(800, 600, 0, 0) + + assertTrue(recorder.grids.isEmpty(), "a missing renderer must not produce a bogus grid") + } + + @Test + fun `degenerate metrics are dropped rather than producing a divide-by-zero grid`() { + val recorder = Recorder() + driver(recorder, metrics = { TerminalCellMetrics(0f, 0) }).onViewSize(800, 600, 0, 0) + + assertTrue(recorder.grids.isEmpty()) + } + + @Test + fun `a normal layout push is not a re-assert`() { + val recorder = Recorder() + driver(recorder).onViewSize(800, 600, 0, 0) + + assertEquals(listOf(Push(TerminalGridSize(80, 30), isReassert = false)), recorder.pushes) + } + + @Test + fun `re-asserting re-pushes the measured grid and flags it as forced`() { + // §6.4 latest-writer-wins: on device switch / pane-show the device that comes back must re-assert + // its dims to win the shared PTY size back. The re-push must be FLAGGED, because the writer + // downstream dedups on its own `lastSentDims` — an unflagged identical grid dies there and the + // reclaim never reaches the wire (the exact double-dedup a review caught). + val recorder = Recorder() + val driver = driver(recorder) + + driver.onViewSize(800, 600, 0, 0) + driver.reassertLastGrid() + + assertEquals( + listOf( + Push(TerminalGridSize(80, 30), isReassert = false), + Push(TerminalGridSize(80, 30), isReassert = true), + ), + recorder.pushes, + ) + } + + @Test + fun `re-asserting does not re-measure, so it works before the next layout pass`() { + // The reclaim fires from a lifecycle/focus callback, NOT from onSizeChanged: there is no new + // layout pass to piggyback on, so the driver must push from its own memo. + val recorder = Recorder() + val driver = driver(recorder) + + driver.onViewSize(800, 600, 0, 0) + driver.reassertLastGrid() + driver.reassertLastGrid() + + assertEquals(3, recorder.pushes.size) + assertTrue(recorder.pushes.drop(1).all { it.isReassert }) + } + + @Test + fun `re-asserting before anything was measured pushes nothing`() { + val recorder = Recorder() + + driver(recorder).reassertLastGrid() + + assertTrue(recorder.pushes.isEmpty(), "there is no grid to re-assert yet; the first layout will push") + } + + @Test + fun `a re-assert leaves the memo intact so the next identical layout is still deduped`() { + val recorder = Recorder() + val driver = driver(recorder) + + driver.onViewSize(800, 600, 0, 0) + driver.reassertLastGrid() + driver.onViewSize(800, 600, 0, 0) + + assertEquals(2, recorder.pushes.size, "layout churn must not ride along on the reclaim") + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGestureTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGestureTest.kt new file mode 100644 index 0000000..24699bf --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGestureTest.kt @@ -0,0 +1,186 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.TerminalEmulator +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.terminalview.StockTerminalViewFixture.ENABLE_APPLICATION_CURSOR_KEYS +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_MOUSE_TRACKING +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed + +/** + * The scroll arithmetic and branch selection, on their own — no view, no `MotionEvent`. + * + * The emulator here is the REAL Termux one driven by REAL escape sequences, so "alternate buffer" and + * "application cursor keys" are the same state the crashing stock branch tests, not a boolean we made up. + */ +class TerminalScrollGestureTest { + + private class FakeTarget(private val emulator: TerminalEmulator?) : TerminalScrollTarget { + val keys = mutableListOf() + val wheelReports = mutableListOf() + var transcriptSteps = 0 + + override fun emulator(): TerminalEmulator? = emulator + override fun scrollTranscript(up: Boolean) { + transcriptSteps += if (up) -1 else 1 + } + + override fun sendKeys(data: String) { + keys += data + } + + override fun reportMouseWheel(up: Boolean) { + wheelReports += up + } + } + + private fun gesture(target: FakeTarget, cellHeightPx: Int = CELL_HEIGHT_PX) = + TerminalScrollGesture(TOUCH_SLOP_PX, { cellHeightPx }, target) + + private fun emulatorWith(vararg sequences: String): TerminalEmulator = + StockTerminalViewFixture.emulator().also { emulator -> sequences.forEach { emulator.feed(it) } } + + @Test + fun `the drag is not claimed until it passes the touch slop`() { + val target = FakeTarget(emulatorWith()) + val scroll = gesture(target) + + assertFalse(scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1))) + assertFalse( + scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - (TOUCH_SLOP_PX - 1), pointerCount = 1)), + "a sub-slop wobble is a tap, and taps must stay with the stock view", + ) + assertTrue(scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - TOUCH_SLOP_PX * 4, pointerCount = 1))) + assertTrue(scroll.isOwned) + } + + @Test + fun `the slop travel is spent on the claim and does not scroll a row`() { + val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER)) + val scroll = gesture(target) + + scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1)) + // A claim that jumps a whole cell would make every drag start with a phantom keystroke. + scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - 5 * CELL_HEIGHT_PX, pointerCount = 1)) + + assertTrue(target.keys.isEmpty()) + } + + @Test + fun `an alternate-screen buffer turns rows into DECCKM-correct arrows`() { + val csi = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER)) + gesture(csi).apply { dragUp(cells = 2) } + + val ss3 = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER, ENABLE_APPLICATION_CURSOR_KEYS)) + gesture(ss3).apply { dragUp(cells = 2) } + + assertEquals(listOf("\u001b[B", "\u001b[B"), csi.keys) + assertEquals(listOf("\u001bOB", "\u001bOB"), ss3.keys, "application-cursor mode must switch form") + } + + @Test + fun `mouse tracking outranks the alternate buffer, exactly like stock doScroll`() { + val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER, ENABLE_MOUSE_TRACKING)) + gesture(target).apply { dragUp(cells = 2) } + + assertEquals(listOf(false, false), target.wheelReports, "two wheel-down reports, no cursor keys") + assertTrue(target.keys.isEmpty()) + } + + @Test + fun `the normal buffer scrolls the transcript and puts nothing on the wire`() { + val target = FakeTarget(emulatorWith()) + gesture(target).apply { dragUp(cells = 3) } + + assertEquals(3, target.transcriptSteps, "dragging up walks toward the newest row") + assertTrue(target.keys.isEmpty()) + assertTrue(target.wheelReports.isEmpty()) + } + + @Test + fun `sub-cell travel accumulates instead of being lost`() { + val target = FakeTarget(emulatorWith()) + val scroll = gesture(target) + scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1)) + scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - CLAIM_TRAVEL_PX, pointerCount = 1)) + + // Three moves of a third of a cell each == exactly one row, not zero. + var y = 500f - CLAIM_TRAVEL_PX + repeat(3) { + y -= CELL_HEIGHT_PX / 3f + scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = y, pointerCount = 1)) + } + + assertEquals(1, target.transcriptSteps) + } + + @Test + fun `a second finger leaves the gesture to the stock pinch detector`() { + val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER)) + val scroll = gesture(target) + + scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1)) + assertFalse(scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 100f, pointerCount = 2))) + assertTrue(target.keys.isEmpty()) + } + + @Test + fun `a wheel notch scrolls three rows, the stock amount`() { + val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER)) + + gesture(target).onMouseWheel(scrollUp = true) + + assertEquals(List(TerminalScrollGesture.MOUSE_WHEEL_ROWS) { "\u001b[A" }, target.keys) + } + + @Test + fun `an unmeasured cell keeps the gesture but scrolls nothing`() { + val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER)) + val scroll = gesture(target, cellHeightPx = 0) + + scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1)) + scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - CLAIM_TRAVEL_PX, pointerCount = 1)) + + // Handing the drag back mid-gesture would put the stock scroll path in play again. + assertTrue(scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 0f, pointerCount = 1))) + assertTrue(target.keys.isEmpty()) + } + + @Test + fun `nothing is emitted while no emulator is bound`() { + val target = FakeTarget(null) + gesture(target).apply { dragUp(cells = 4) } + + assertTrue(target.keys.isEmpty()) + assertEquals(0, target.transcriptSteps) + } + + @Test + fun `the gesture ends and the next drag has to re-earn its claim`() { + val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER)) + val scroll = gesture(target) + scroll.dragUp(cells = 1) + + assertFalse(scroll.isOwned, "an ended gesture must not leak ownership into the next tap") + assertFalse(scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1))) + } + + /** Claim the drag, then travel [cells] whole cells upward, then lift. */ + private fun TerminalScrollGesture.dragUp(cells: Int) { + onTouch(TouchFacts(TouchPhase.DOWN, y = START_Y, pointerCount = 1)) + onTouch(TouchFacts(TouchPhase.MOVE, y = START_Y - CLAIM_TRAVEL_PX, pointerCount = 1)) + val end = START_Y - CLAIM_TRAVEL_PX - cells * CELL_HEIGHT_PX + onTouch(TouchFacts(TouchPhase.MOVE, y = end, pointerCount = 1)) + onTouch(TouchFacts(TouchPhase.END, y = end, pointerCount = 1)) + } + + private companion object { + const val TOUCH_SLOP_PX: Int = 8 + const val CELL_HEIGHT_PX: Int = 24 + const val START_Y: Float = 800f + const val CLAIM_TRAVEL_PX: Float = 100f + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBufferTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBufferTest.kt new file mode 100644 index 0000000..e1c8eb1 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBufferTest.kt @@ -0,0 +1,186 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.TerminalEmulator +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.terminalview.StockTerminalViewFixture.feed + +/** + * Text extraction, against the **real** Termux `TerminalBuffer` driven by real bytes. + * + * This is the part of the stock selection stack that is session-free and therefore reusable: + * `TerminalBuffer.getSelectedText` reads rows and nothing else — no `TerminalSession`, no view. Re-deriving + * its wide-cell walk (`TerminalRow.findStartOfColumn` + `WcWidth`), its per-line trailing-blank trim and its + * soft-wrap joining in Kotlin would be a second implementation of the same subtle logic, guaranteed to + * drift from what the renderer draws. So the extraction is delegated and PINNED here instead: these tests + * assert the behaviour this module depends on, so a Termux bump that changes it fails loudly. + */ +class TerminalSelectionBufferTest { + + private fun bufferOf(emulator: TerminalEmulator?): TerminalSelectionBuffer = + TerminalEmulatorSelectionBuffer { emulator } + + private fun emulatorWith(vararg output: String): TerminalEmulator = + StockTerminalViewFixture.emulator().also { emulator -> output.forEach { emulator.feed(it) } } + + private fun TerminalSelectionBuffer.text(fromColumn: Int, fromRow: Int, toColumn: Int, toRow: Int) = + textIn(TerminalSelectionSpan.spanning(TerminalCell(fromColumn, fromRow), TerminalCell(toColumn, toRow))) + + // ── Bounds ─────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `bounds come from the live emulator and its transcript`() { + val emulator = emulatorWith() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + + val bounds = bufferOf(emulator).bounds() + + assertEquals(TerminalGridBounds(COLUMNS, SCREEN_ROWS, emulator.screen.activeTranscriptRows), bounds) + assertTrue(bounds!!.transcriptRows > 0, "40 lines on a 24-row screen must leave a transcript") + } + + @Test + fun `there are no bounds and no text before an emulator is bound`() { + val buffer = bufferOf(null) + + assertNull(buffer.bounds()) + assertEquals("", buffer.text(0, 0, 5, 0)) + } + + // ── Trailing blanks, wide cells, line joining ──────────────────────────────────────────────────── + + @Test + fun `a row's trailing blanks are trimmed rather than copied as spaces`() { + val buffer = bufferOf(emulatorWith("hi")) + + // Selecting the whole row must not yield "hi" followed by 78 spaces. + assertEquals("hi", buffer.text(0, 0, COLUMNS - 1, 0)) + } + + @Test + fun `a wide CJK cell is copied whole from either of its two columns`() { + // 你 occupies columns 0-1 and 好 columns 2-3; "world" then starts at column 4. + val buffer = bufferOf(emulatorWith("你好world")) + + assertEquals("你", buffer.text(0, 0, 1, 0)) + assertEquals("你", buffer.text(1, 0, 1, 0), "the right half of a wide cell is still that cell") + assertEquals("你好", buffer.text(0, 0, 3, 0)) + assertEquals("好world", buffer.text(2, 0, 8, 0)) + } + + @Test + fun `a soft-wrapped line is joined, so a long command copies as one line`() { + val buffer = bufferOf(emulatorWith("a".repeat(WRAPPED_LINE_LENGTH))) + + val copied = buffer.text(0, 0, WRAPPED_LINE_LENGTH - COLUMNS - 1, 1) + + assertEquals("a".repeat(WRAPPED_LINE_LENGTH), copied) + assertFalse(copied.contains('\n'), "a wrap point is not a line break") + } + + @Test + fun `a real line break is preserved`() { + val buffer = bufferOf(emulatorWith("one\r\ntwo\r\n")) + + assertEquals("one\ntwo", buffer.text(0, 0, 2, 1)) + } + + @Test + fun `a multi-row selection takes the first row to the edge and the last to the focus`() { + val buffer = bufferOf(emulatorWith("alpha\r\nbravo\r\ncharlie\r\n")) + + assertEquals("pha\nbravo\ncha", buffer.text(2, 0, 2, 2)) + } + + @Test + fun `scrollback rows are readable at negative row indices`() { + val emulator = emulatorWith() + repeat(SCROLLED_OFF_LINES) { index -> emulator.feed("line-$index\r\n") } + + // 40 lines plus the trailing empty one scroll 17 rows off a 24-row screen, so screen row 0 holds + // line-17 and transcript row -1 holds the line just above it. + assertEquals("line-${SCROLLED_OFF_LINES - SCREEN_ROWS}", bufferOf(emulator).text(0, -1, COLUMNS - 1, -1)) + } + + // ── The clamp that keeps the two unguarded stock failures unreachable ──────────────────────────── + + @Test + fun `an off-grid span is clamped instead of throwing out of the stock row walk`() { + val buffer = bufferOf(emulatorWith("hello")) + + // Row -9999: TerminalBuffer.externalToInternalRow throws IllegalArgumentException outside + // -activeTranscriptRows..screenRows. Column 9999: TerminalRow.findStartOfColumn walks mText with + // no bound check for column > mColumns. Both are reachable from a touch outside the grid. + // The clamp turns the span into "the whole measured grid", i.e. the one written row plus the blank + // rows below it — the point being that it RETURNS rather than throwing. + assertEquals("hello", buffer.text(-50, -9999, 9999, 9999).trim()) + } + + @Test + fun `an unmeasured emulator yields no text`() { + // A 0-column emulator cannot be constructed by Termux, so the unmeasured case is "no emulator"; + // proving the clamp refuses rather than divides is still worth pinning. + assertNull(TerminalGridBounds(columns = 0, screenRows = 0, transcriptRows = 0).takeIf { it.isMeasured }) + } + + // ── Long-press word snap ──────────────────────────────────────────────────────────────────────── + + @Test + fun `a long press snaps to the run of non-blank cells around it`() { + val buffer = bufferOf(emulatorWith("cat /etc/hosts")) + + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 6, row = 0), buffer) + + assertEquals(TerminalCell(4, 0), selection?.span?.start) + assertEquals(TerminalCell(13, 0), selection?.span?.end) + assertEquals("/etc/hosts", buffer.textIn(selection!!.span)) + } + + @Test + fun `the snap stops at the start of the row`() { + val buffer = bufferOf(emulatorWith("cat /etc/hosts")) + + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 1, row = 0), buffer) + + assertEquals("cat", buffer.textIn(selection!!.span)) + } + + @Test + fun `the snap spans wide cells`() { + val buffer = bufferOf(emulatorWith("你好 world")) + + // Column 1 is the right half of 你; the run is 你好, i.e. columns 0-3. + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 1, row = 0), buffer) + + assertEquals("你好", buffer.textIn(selection!!.span)) + } + + @Test + fun `a long press on blank space selects just that cell`() { + val buffer = bufferOf(emulatorWith("cat /etc/hosts")) + + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 3, row = 0), buffer) + + assertTrue(selection!!.span.isSingleCell, "a blank cell must not swallow the words either side") + assertEquals(TerminalCell(3, 0), selection.span.start) + } + + @Test + fun `there is nothing to snap to before an emulator is bound`() { + assertNull(TerminalSelectionWords.runAt(TerminalCell(0, 0), bufferOf(null))) + } + + private companion object { + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + + /** Enough output to push rows off the top so there is a transcript to read. */ + const val SCROLLED_OFF_LINES: Int = 40 + + /** Longer than one row, so the emulator sets the row's wrap flag. */ + const val WRAPPED_LINE_LENGTH: Int = 100 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionControllerTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionControllerTest.kt new file mode 100644 index 0000000..49ba45e --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionControllerTest.kt @@ -0,0 +1,249 @@ +package wang.yaojia.webterm.terminalview + +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 + +/** + * The selection state machine, with the buffer and the clipboard faked so the DECISIONS are asserted + * rather than the framework: when a selection exists, what a drag does to it, what a content scroll does + * to it, and — the §8 rule — that terminal text reaches the clipboard on an explicit copy and at no other + * moment. + */ +class TerminalSelectionControllerTest { + + /** A one-row grid of text, enough to drive the state machine; real extraction is pinned elsewhere. */ + private class FakeBuffer( + private val row: String = "hello world", + private val bounds: TerminalGridBounds? = TerminalGridBounds(COLUMNS, SCREEN_ROWS, TRANSCRIPT_ROWS), + ) : TerminalSelectionBuffer { + var reads = 0 + + override fun bounds(): TerminalGridBounds? = bounds + + override fun textIn(span: TerminalSelectionSpan): String { + reads++ + val bounds = bounds ?: return "" + if (span.start.row != span.end.row) return row + val columns = span.columnsIn(span.start.row, bounds.lastColumn) ?: return "" + return row.substring( + columns.first.coerceIn(0, row.length), + (columns.last + 1).coerceIn(0, row.length), + ).trimEnd() + } + } + + private class FakeClipboard(private val accepts: Boolean = true) : TerminalClipboard { + val puts = mutableListOf() + + override fun put(text: String): Boolean { + puts += text + return accepts + } + } + + private class Recorder { + val changes = mutableListOf() + } + + private fun controller( + buffer: TerminalSelectionBuffer = FakeBuffer(), + clipboard: TerminalClipboard = FakeClipboard(), + recorder: Recorder = Recorder(), + ) = TerminalSelectionController(buffer, clipboard) { recorder.changes += it } + + // ── Begin / extend ─────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a long press starts a selection snapped to the word under it`() { + val recorder = Recorder() + val controller = controller(recorder = recorder) + + assertTrue(controller.beginAt(TerminalCell(column = 2, row = 0))) + + assertTrue(controller.isActive) + assertEquals(TerminalCell(0, 0), controller.selection?.span?.start) + assertEquals(TerminalCell(4, 0), controller.selection?.span?.end, "'hello' is columns 0-4") + assertEquals(listOf(true), recorder.changes) + } + + @Test + fun `a drag moves the focus and leaves the anchor where the press landed`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + + controller.extendTo(TerminalCell(column = 8, row = 0)) + + assertEquals(TerminalCell(0, 0), controller.selection?.span?.start, "the anchor survives the drag") + assertEquals(TerminalCell(8, 0), controller.selection?.span?.end) + } + + @Test + fun `dragging back past the anchor flips the span and keeps the snapped word`() { + val controller = controller() + controller.beginAt(TerminalCell(8, 0)) // snaps to "world", columns 6-10 + controller.extendTo(TerminalCell(column = 1, row = 0)) + + val span = controller.selection?.span + + assertEquals(TerminalCell(1, 0), span?.start) + assertEquals( + TerminalCell(10, 0), + span?.end, + "the whole anchored word stays in — a single-cell anchor would clip 'world' to its 'w'", + ) + } + + @Test + fun `a drag off the grid is clamped, not stored raw`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + + controller.extendTo(TerminalCell(column = 9_999, row = 9_999)) + + assertEquals(TerminalCell(COLUMNS - 1, SCREEN_ROWS - 1), controller.selection?.span?.end) + } + + @Test + fun `nothing can be selected before the grid is measured`() { + val controller = controller(buffer = FakeBuffer(bounds = null)) + + assertFalse(controller.beginAt(TerminalCell(2, 0))) + assertFalse(controller.isActive) + } + + @Test + fun `extending without a selection does nothing`() { + val controller = controller() + + controller.extendTo(TerminalCell(4, 0)) + + assertFalse(controller.isActive) + } + + // ── Copy (the only path to the clipboard) ──────────────────────────────────────────────────────── + + @Test + fun `selecting text puts nothing on the clipboard`() { + // Plan §8: terminal content is untrusted and may be a secret. It reaches the clipboard ONLY on an + // explicit user copy — never on selection, never on drag, never automatically. + val clipboard = FakeClipboard() + val controller = controller(clipboard = clipboard) + + controller.beginAt(TerminalCell(2, 0)) + controller.extendTo(TerminalCell(8, 0)) + controller.clear() + + assertTrue(clipboard.puts.isEmpty()) + } + + @Test + fun `an explicit copy puts exactly the selected text on the clipboard once`() { + val clipboard = FakeClipboard() + val controller = controller(clipboard = clipboard) + controller.beginAt(TerminalCell(2, 0)) + + assertEquals(TerminalCopyResult.COPIED, controller.copy()) + + assertEquals(listOf("hello"), clipboard.puts) + } + + @Test + fun `copying with no selection is refused and touches nothing`() { + val clipboard = FakeClipboard() + val controller = controller(clipboard = clipboard) + + assertEquals(TerminalCopyResult.NOTHING_SELECTED, controller.copy()) + assertTrue(clipboard.puts.isEmpty()) + } + + @Test + fun `a blank selection is never written to the clipboard`() { + val clipboard = FakeClipboard() + val controller = controller(buffer = FakeBuffer(row = " "), clipboard = clipboard) + controller.beginAt(TerminalCell(1, 0)) + + assertEquals(TerminalCopyResult.NOTHING_TO_COPY, controller.copy()) + assertTrue(clipboard.puts.isEmpty(), "clearing the user's clipboard with an empty string is data loss") + } + + @Test + fun `a refused clipboard write is reported instead of being reported as success`() { + val controller = controller(clipboard = FakeClipboard(accepts = false)) + controller.beginAt(TerminalCell(2, 0)) + + assertEquals(TerminalCopyResult.CLIPBOARD_UNAVAILABLE, controller.copy()) + } + + // ── Clear ─────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `clearing drops the selection and reports the change once`() { + val recorder = Recorder() + val controller = controller(recorder = recorder) + controller.beginAt(TerminalCell(2, 0)) + + controller.clear() + + assertFalse(controller.isActive) + assertNull(controller.selection) + assertEquals(listOf(true, false), recorder.changes) + } + + @Test + fun `clearing an empty selection reports nothing`() { + // Load-bearing: the copy affordance is dismissed by clear(), and its own dismissal calls clear() + // back. Without this guard that is an infinite loop. + val recorder = Recorder() + val controller = controller(recorder = recorder) + + controller.clear() + controller.clear() + + assertTrue(recorder.changes.isEmpty()) + } + + // ── Content scrolling under a live selection ──────────────────────────────────────────────────── + + @Test + fun `new output shifts the selection so it keeps pointing at the same text`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + + controller.onContentScrolled(scrolledRows = 3) + + assertEquals(TerminalCell(0, -3), controller.selection?.span?.start) + assertEquals(TerminalCell(4, -3), controller.selection?.span?.end) + } + + @Test + fun `a selection scrolled out of the retained transcript is dropped, not left pointing at nothing`() { + val recorder = Recorder() + val controller = controller(recorder = recorder) + controller.beginAt(TerminalCell(2, 0)) + + controller.onContentScrolled(scrolledRows = TRANSCRIPT_ROWS + 1) + + assertFalse(controller.isActive) + assertEquals(listOf(true, false), recorder.changes) + } + + @Test + fun `a scroll of zero rows is not a change`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + val before = controller.selection + + controller.onContentScrolled(scrolledRows = 0) + + assertEquals(before, controller.selection) + } + + private companion object { + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + const val TRANSCRIPT_ROWS: Int = 100 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometryTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometryTest.kt new file mode 100644 index 0000000..4a54352 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometryTest.kt @@ -0,0 +1,117 @@ +package wang.yaojia.webterm.terminalview + +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 + +/** + * Pixels ↔ cells, and the highlight bands. + * + * The mapping is deliberately the INVERSE of the stock accessors this module already reads + * (`TerminalView.getPointX(col)` = `round(col * fontWidth)`, `getPointY(row)` = `(row - topRow) * + * lineSpacing`), so a tapped cell and its highlight band are the same cell. Stock's own + * `getColumnAndRow` biases the row by `mFontLineSpacingAndAscent` instead, which puts its hit test + * ~20% of a row above the glyph it is drawing — not something to reproduce. + */ +class TerminalSelectionGeometryTest { + + @Test + fun `a touch resolves to the cell under it`() { + val cell = TerminalSelectionGeometry.cellAt( + xPx = 2.5f * CELL_WIDTH_PX, + yPx = 3.5f * CELL_HEIGHT_PX, + metrics = METRICS, + topRow = 0, + ) + + assertEquals(TerminalCell(column = 2, row = 3), cell) + } + + @Test + fun `a touch while scrolled back resolves into the transcript`() { + val cell = TerminalSelectionGeometry.cellAt( + xPx = 0f, + yPx = 0.5f * CELL_HEIGHT_PX, + metrics = METRICS, + topRow = -7, + ) + + assertEquals(TerminalCell(column = 0, row = -7), cell, "row 0 of the viewport is topRow") + } + + @Test + fun `an unmeasured cell box resolves to no cell instead of dividing by zero`() { + assertNull( + TerminalSelectionGeometry.cellAt(10f, 10f, TerminalCellMetrics(0f, 0), topRow = 0), + ) + assertNull( + TerminalSelectionGeometry.cellAt(10f, 10f, TerminalCellMetrics(CELL_WIDTH_PX, 0), topRow = 0), + ) + } + + @Test + fun `a one-row selection is one band, sized to the selected columns`() { + val span = TerminalSelectionSpan.spanning(TerminalCell(2, 1), TerminalCell(4, 1)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0) + + assertEquals( + listOf( + TerminalSelectionRect( + left = 2 * CELL_WIDTH_PX, + top = 1f * CELL_HEIGHT_PX, + right = 5 * CELL_WIDTH_PX, + bottom = 2f * CELL_HEIGHT_PX, + ), + ), + rects, + ) + } + + @Test + fun `a multi-row selection bands the first row to the right edge and the last from the left`() { + val span = TerminalSelectionSpan.spanning(TerminalCell(3, 0), TerminalCell(1, 2)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0) + + assertEquals(3, rects.size) + assertEquals(3 * CELL_WIDTH_PX, rects[0].left) + assertEquals(COLUMNS * CELL_WIDTH_PX, rects[0].right, "the first row runs to the grid edge") + assertEquals(0f, rects[1].left) + assertEquals(COLUMNS * CELL_WIDTH_PX, rects[1].right, "a middle row is fully highlighted") + assertEquals(0f, rects[2].left) + assertEquals(2 * CELL_WIDTH_PX, rects[2].right) + } + + @Test + fun `rows scrolled out of the viewport produce no bands`() { + // A selection made in scrollback, then scrolled away: nothing to paint, and nothing that would + // paint at a negative y over the live screen. + val span = TerminalSelectionSpan.spanning(TerminalCell(0, -40), TerminalCell(5, -38)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0) + + assertTrue(rects.isEmpty()) + } + + @Test + fun `a selection straddling the top of the viewport is clipped to the visible rows`() { + val span = TerminalSelectionSpan.spanning(TerminalCell(0, -3), TerminalCell(5, 1)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = -1) + + assertEquals(3, rects.size, "rows -1, 0 and 1 are visible; -3 and -2 are above the viewport") + assertEquals(0f, rects[0].top, "the first visible band starts at the top of the view") + } + + private companion object { + const val CELL_WIDTH_PX: Float = 12f + const val CELL_HEIGHT_PX: Int = 24 + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + + val METRICS = TerminalCellMetrics(CELL_WIDTH_PX, CELL_HEIGHT_PX) + val BOUNDS = TerminalGridBounds(COLUMNS, SCREEN_ROWS, transcriptRows = 100) + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModelTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModelTest.kt new file mode 100644 index 0000000..293ead9 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModelTest.kt @@ -0,0 +1,158 @@ +package wang.yaojia.webterm.terminalview + +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 + +/** + * The selection model on its own: cell coordinates, normalisation, clamping and the per-row column + * ranges. No view, no emulator, no android — this is the part that has to be right before any pixel or + * any clipboard write can be. + */ +class TerminalSelectionModelTest { + + // ── Normalisation ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a backwards drag is the same span as the forwards drag`() { + val forwards = selection(cell(2, 1), cell(7, 4)) + val backwards = selection(cell(7, 4), cell(2, 1)) + + assertEquals(forwards.span, backwards.span) + assertEquals(cell(2, 1), forwards.span.start) + assertEquals(cell(7, 4), forwards.span.end) + } + + @Test + fun `a backwards drag inside one row swaps the columns only`() { + val span = selection(cell(9, 3), cell(4, 3)).span + + assertEquals(cell(4, 3), span.start) + assertEquals(cell(9, 3), span.end) + } + + @Test + fun `ordering is row-major, so a lower row wins even with a bigger column`() { + // Dragging up-and-right must not be read as a forwards selection: the row decides. + val span = selection(cell(0, 5), cell(70, 2)).span + + assertEquals(cell(70, 2), span.start) + assertEquals(cell(0, 5), span.end) + } + + @Test + fun `a one-cell selection is a one-cell span`() { + val span = selection(cell(5, 5), cell(5, 5)).span + + assertEquals(span.start, span.end) + assertTrue(span.isSingleCell) + } + + // ── Per-row column ranges (what both the highlight and the extraction use) ─────────────────────── + + @Test + fun `the first row runs to the last column and the last row starts at column zero`() { + val span = selection(cell(3, 0), cell(6, 2)).span + + assertEquals(3..LAST_COLUMN, span.columnsIn(0, LAST_COLUMN)) + assertEquals(0..LAST_COLUMN, span.columnsIn(1, LAST_COLUMN), "a middle row is fully selected") + assertEquals(0..6, span.columnsIn(2, LAST_COLUMN)) + } + + @Test + fun `a single-row span uses both of its columns`() { + val span = selection(cell(3, 4), cell(6, 4)).span + + assertEquals(3..6, span.columnsIn(4, LAST_COLUMN)) + } + + @Test + fun `rows outside the span have no column range`() { + val span = selection(cell(3, 2), cell(6, 4)).span + + assertNull(span.columnsIn(1, LAST_COLUMN)) + assertNull(span.columnsIn(5, LAST_COLUMN)) + } + + // ── Clamping (the reason an off-grid touch cannot crash the buffer read) ───────────────────────── + + @Test + fun `an off-grid touch clamps into the grid`() { + // TerminalRow.findStartOfColumn walks mText with no bound check for column > mColumns, and + // TerminalBuffer.externalToInternalRow THROWS for a row outside the transcript. Clamping here is + // what keeps both unreachable. + val span = selection(cell(-4, -900), cell(500, 99)).span.clampedTo(BOUNDS) + + assertEquals(cell(0, -TRANSCRIPT_ROWS), span?.start) + assertEquals(cell(LAST_COLUMN, SCREEN_ROWS - 1), span?.end) + } + + @Test + fun `an in-grid span is left alone by the clamp`() { + val span = selection(cell(1, -2), cell(9, 3)).span + + assertEquals(span, span.clampedTo(BOUNDS)) + } + + @Test + fun `an unmeasured grid clamps to nothing rather than to cell zero`() { + val span = selection(cell(1, 1), cell(2, 2)).span + + assertNull(span.clampedTo(TerminalGridBounds(columns = 0, screenRows = 0, transcriptRows = 0))) + } + + // ── The anchor is a SPAN, because a long press snaps to a word ─────────────────────────────────── + + @Test + fun `a word-anchored selection starts as exactly that word`() { + val selection = TerminalSelection.anchoredOn(cell(4, 1), cell(13, 1)) + + assertEquals(cell(4, 1), selection.span.start) + assertEquals(cell(13, 1), selection.span.end) + assertEquals(cell(13, 1), selection.focus, "the focus starts at the end the drag continues from") + } + + @Test + fun `dragging back past a word-anchored selection keeps the whole word`() { + // The editor behaviour: the word you long-pressed stays selected however you drag. Anchoring on a + // single cell instead would collapse "charlie" to its first character the moment the finger moved + // above it. + val dragged = TerminalSelection.anchoredOn(cell(0, 2), cell(6, 2)).withFocus(cell(2, 0)) + + assertEquals(cell(2, 0), dragged.span.start) + assertEquals(cell(6, 2), dragged.span.end) + } + + @Test + fun `a focus inside the anchor changes nothing`() { + val anchored = TerminalSelection.anchoredOn(cell(4, 1), cell(13, 1)) + + assertEquals(anchored.span, anchored.withFocus(cell(8, 1)).span) + } + + // ── Content scrolling ─────────────────────────────────────────────────────────────────────────── + + @Test + fun `new output shifts the selection up by the scrolled rows`() { + // External row indices move when the screen scrolls: the content the user selected is one row + // higher after one new line. Without this the highlight (and the copy) would silently drift. + val moved = selection(cell(2, 5), cell(4, 6)).movedBy(-3) + + assertEquals(cell(2, 2), moved.span.start) + assertEquals(cell(4, 3), moved.span.end) + } + + private companion object { + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + const val TRANSCRIPT_ROWS: Int = 100 + const val LAST_COLUMN: Int = COLUMNS - 1 + val BOUNDS = TerminalGridBounds(COLUMNS, SCREEN_ROWS, TRANSCRIPT_ROWS) + + fun cell(column: Int, row: Int) = TerminalCell(column, row) + + /** A press at [from] with the finger now at [to] — the one-cell-anchor case. */ + fun selection(from: TerminalCell, to: TerminalCell) = TerminalSelection.at(from).withFocus(to) + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt new file mode 100644 index 0000000..9c1052c --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt @@ -0,0 +1,200 @@ +package wang.yaojia.webterm.terminalview + +import android.view.KeyEvent +import android.view.MotionEvent +import io.mockk.every +import io.mockk.mockk +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 + +/** + * The client end-to-end: a key press arrives the way Termux delivers it — with a **null** + * `TerminalSession`, exactly as `TerminalView.onKeyDown` passes its own null `mTermSession` at offset + * 88–92 — and must come out as bytes on the sink, with `true` returned so the stock method stops at + * `invalidate(); return true` (offset 97–105) instead of walking on into the session it does not have. + * + * `KeyEvent` here is the AGP mockable stub (`isReturnDefaultValues = true`), so every accessor reports + * "unmodified, non-system, no keymap character" — which is precisely the shape of a bare arrow press, + * the case that matters for DECCKM. + */ +class WebTermTerminalViewClientTest { + + private class FakeSink( + private val appHandles: Boolean = false, + private var modes: TerminalCursorModes? = TerminalCursorModes(false, false), + ) : TerminalInputSink { + val sent = mutableListOf() + var taps = 0 + var sizes = mutableListOf>() + var keysOfferedToApp = 0 + val longPresses = mutableListOf>() + + fun setCursorApplicationMode(on: Boolean) { + modes = TerminalCursorModes(cursorKeysApplication = on, keypadApplication = false) + } + + override fun appConsumesKey(keyCode: Int, event: KeyEvent): Boolean { + keysOfferedToApp++ + return appHandles + } + + override fun cursorModes(): TerminalCursorModes? = modes + override fun sendInput(data: String) { + sent += data + } + + override fun onTapped() { + taps++ + } + + override fun onLongPressAt(xPx: Float, yPx: Float) { + longPresses += xPx to yPx + } + + override fun onViewSize(widthPx: Int, heightPx: Int) { + sizes += widthPx to heightPx + } + } + + private fun downEvent() = KeyEvent(KeyEvent.ACTION_DOWN, 0) + private fun upEvent() = KeyEvent(KeyEvent.ACTION_UP, 0) + + @Test + fun `a key press with a null session emits bytes and is consumed`() { + val sink = FakeSink() + val client = WebTermTerminalViewClient(sink) + + // `session = null` is what the stock view actually passes — the very argument that made the old + // "return false and let Termux write it" shape impossible. + val consumed = client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null) + + assertTrue(consumed, "returning false would let TerminalView fall through toward mTermSession") + assertEquals(listOf("\u001b[A"), sink.sent) + } + + @Test + fun `the emulator's live DECCKM mode selects SS3 over CSI`() { + val sink = FakeSink() + val client = WebTermTerminalViewClient(sink) + + client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null) + sink.setCursorApplicationMode(true) + client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null) + + assertEquals(listOf("\u001b[A", "\u001bOA"), sink.sent, "vim/htop need SS3 once DECCKM is set") + } + + @Test + fun `Enter sends CR`() { + val sink = FakeSink() + WebTermTerminalViewClient(sink).onKeyDown(KeyEvent.KEYCODE_ENTER, downEvent(), null) + + assertEquals(listOf("\r"), sink.sent) + } + + @Test + fun `an app-handled chord is offered to the outlet and not double-sent`() { + val sink = FakeSink(appHandles = true) + val client = WebTermTerminalViewClient(sink) + + val consumed = client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null) + + assertTrue(consumed) + assertEquals(1, sink.keysOfferedToApp, ":app's HardwareKeyRouter must get first refusal") + assertTrue(sink.sent.isEmpty(), ":app already sent the bytes; sending again would double-type") + } + + @Test + fun `key-up is consumed and sends nothing`() { + val sink = FakeSink() + val consumed = WebTermTerminalViewClient(sink).onKeyUp(KeyEvent.KEYCODE_DPAD_UP, upEvent()) + + assertTrue(consumed) + assertTrue(sink.sent.isEmpty()) + } + + @Test + fun `onCodePoint never falls through to the session`() { + val sink = FakeSink() + val handled = WebTermTerminalViewClient(sink).onCodePoint('c'.code, ctrlDown = true, session = null) + + assertTrue(handled, "false here would let inputCodePoint reach mTermSession.writeCodePoint") + assertEquals(listOf("\u0003"), sink.sent) + } + + @Test + fun `the back button is never mapped to escape`() { + // True would send a system BACK press down TerminalView.onKeyDown's terminal branch (offset + // 113-136), whose next stop is mTermSession.write(...). This flag is load-bearing, not cosmetic. + assertFalse(WebTermTerminalViewClient(FakeSink()).shouldBackButtonBeMappedToEscape()) + } + + @Test + fun `a long press is consumed so the stock text-selection mode never starts`() { + // TerminalView$1.onLongPress offsets 14-30: `if (mClient.onLongPress(e)) return;`. Returning false + // lets it call startTextSelectionMode, whose ActionMode is a crash, not a feature — "Copy" + // (TextSelectionCursorController$1.onActionItemClicked offsets 89-100) is + // `mTermSession.onCopyTextToClipboard(...)` and "Paste" (offsets 126-136) is + // `mTermSession.onPasteTextFromClipboard()`, both unguarded against the session we never have. + assertTrue( + WebTermTerminalViewClient(FakeSink()).onLongPress(null), + "false here arms an ActionMode whose Copy button NPEs on mTermSession", + ) + } + + @Test + fun `a long press starts the first-party selection at the pressed pixels`() { + // Consuming the event is what keeps the stock mode unreachable; forwarding the coordinates is what + // turns the consumed press into the replacement feature instead of a discarded gesture. + val sink = FakeSink() + val event = mockk(relaxed = true) + every { event.x } returns PRESS_X_PX + every { event.y } returns PRESS_Y_PX + + assertTrue(WebTermTerminalViewClient(sink).onLongPress(event)) + + assertEquals(listOf(PRESS_X_PX to PRESS_Y_PX), sink.longPresses) + } + + @Test + fun `a tap raises the keyboard and pinch-zoom is declined`() { + val sink = FakeSink() + val client = WebTermTerminalViewClient(sink) + + client.onSingleTapUp(null) + + assertEquals(1, sink.taps) + assertEquals(1.0f, client.onScale(3.0f), "a changed font size would silently break the grid maths") + } + + @Test + fun `logging callbacks are inert so keystrokes never reach logcat`() { + val client = WebTermTerminalViewClient(FakeSink()) + // Termux funnels key codes and typed text through these; they must be no-ops (plan §8). + client.logInfo("tag", "onKeyDown(keyCode=31, event=…)") + client.logError("tag", "secret") + client.logWarn("tag", "secret") + client.logDebug("tag", "secret") + client.logVerbose("tag", "secret") + client.logStackTrace("tag", Exception("secret")) + client.logStackTraceWithMessage("tag", "secret", Exception("secret")) + } + + @Test + fun `no modifier latching leaks into the key tables`() { + val client = WebTermTerminalViewClient(FakeSink()) + // Termux's extra-keys bar latches these; ours sends finished byte sequences instead, so a latched + // "true" here would silently add Ctrl/Alt to every subsequent keystroke. + assertFalse(client.readControlKey()) + assertFalse(client.readAltKey()) + assertFalse(client.readShiftKey()) + assertFalse(client.readFnKey()) + } + + private companion object { + const val PRESS_X_PX: Float = 42f + const val PRESS_Y_PX: Float = 96f + } +} diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt new file mode 100644 index 0000000..2ea293c --- /dev/null +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt @@ -0,0 +1,173 @@ +package wang.yaojia.webterm.transport + +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import java.util.concurrent.ConcurrentHashMap + +/** + * The client's cookie jar for the optional `WEBTERM_TOKEN` gate (server `src/http/auth.ts`). + * + * When a host is token-gated, `GET /?token=…` (or `POST /auth`) answers with + * `Set-Cookie: webterm_auth=…; HttpOnly; SameSite=Strict[; Secure]`, and from then on EVERY remote + * HTTP route **and the WS handshake** require that cookie. The upgrade is a plain HTTP request, so + * installing this jar on the one shared [okhttp3.OkHttpClient] covers both paths at once — OkHttp's + * `BridgeInterceptor` runs for WebSocket calls too. + * + * ── Isolation (security-load-bearing) ──────────────────────────────────────────────────────────── + * The cookie is a **shell credential**. Two independent layers keep it on its own host: + * 1. storage is partitioned by [authCookieHostKey] (`scheme://host:port`), so another host's key + * simply has no entry to read; + * 2. what survives that lookup is still filtered through OkHttp's own `Cookie.matches(url)`, which + * applies the RFC domain/path rules and refuses to put a `Secure` cookie on cleartext. + * + * Expiry is enforced on BOTH sides of the boundary: expired entries are pruned when read (and the + * pruning is pushed to storage) and dropped when restored, so a stale credential is never + * resurrected after a long background or a clock jump. + * + * Everything is in-memory and synchronous — OkHttp calls a `CookieJar` on the call thread. + * Durability is the [AuthCookiePersister]'s job (`:host-registry` DataStore, adapted in `:app`); + * [restore] rehydrates at cold start. A cookie jar is NOT a cache: the shared client keeps + * `.cache(null)` (plan §8) untouched. + * + * @param persister where a host's changed cookie set is handed for durable storage. + * @param clock wall-clock source (injected for tests); must be the same epoch as `expiresAt`. + */ +public class AuthCookieJar( + private val persister: AuthCookiePersister = AuthCookiePersister.NONE, + private val clock: () -> Long = System::currentTimeMillis, +) : CookieJar { + + /** hostKey → that host's cookies. Values are immutable lists, replaced wholesale, never mutated. */ + private val byHostKey = ConcurrentHashMap>() + + /** Serialises the read-modify-write of a host's list (reads stay lock-free through the map). */ + private val writeLock = Any() + + override fun loadForRequest(url: HttpUrl): List { + val hostKey = authCookieHostKey(url) + val stored = byHostKey[hostKey] ?: return emptyList() + val live = pruneExpired(hostKey, stored) + // Second layer: RFC domain/path match + the Secure-over-cleartext refusal. + return live.filter { it.matches(url) } + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + if (cookies.isEmpty()) return + val hostKey = authCookieHostKey(url) + val now = clock() + synchronized(writeLock) { + store(hostKey, byHostKey[hostKey].orEmpty().merged(cookies, now)) + } + } + + /** + * Rehydrate one host's cookies at cold start (from `:host-registry`). Already-expired snapshots + * and structurally invalid ones are dropped. This is a hydration path, not a change, so the + * [AuthCookiePersister] is deliberately NOT notified. + */ + public fun restore(hostKey: String, cookies: List) { + val now = clock() + val live = cookies + .filter { it.expiresAtEpochMillis > now } + .mapNotNull { it.toCookieOrNull() } + .capped() + synchronized(writeLock) { + if (live.isEmpty()) byHostKey.remove(hostKey) else byHostKey[hostKey] = live + } + } + + /** + * Forget one host's cookies (unpair / "sign out of this host") and tell storage to do the same. + * Other hosts are untouched. + */ + public fun clear(hostKey: String) { + synchronized(writeLock) { byHostKey.remove(hostKey) } + persister.persist(hostKey, emptyList()) + } + + /** Redacted by construction — a cookie jar's contents are credentials. */ + override fun toString(): String = "AuthCookieJar(hosts=${byHostKey.size})" + + // ── internals ─────────────────────────────────────────────────────────────────────────────── + + /** Drops expired entries, publishing the pruned set (so storage forgets them too) iff it changed. */ + private fun pruneExpired(hostKey: String, stored: List): List { + val now = clock() + val live = stored.filter { it.expiresAt > now } + if (live.size == stored.size) return stored + synchronized(writeLock) { + // Re-read under the lock: a concurrent save may have replaced the list meanwhile. + val current = byHostKey[hostKey].orEmpty() + store(hostKey, current.filter { it.expiresAt > now }) + } + return live + } + + /** The single write path: cap, publish in memory, then hand the persistent subset to storage. */ + private fun store(hostKey: String, cookies: List) { + val capped = cookies.capped() + if (capped.isEmpty()) byHostKey.remove(hostKey) else byHostKey[hostKey] = capped + // Session cookies (no Max-Age/Expires) must not outlive the process — memory only. + persister.persist(hostKey, capped.filter { it.persistent }.map { it.toSnapshot() }) + } +} + +/** Keep the most recent [MAX_COOKIES_PER_HOST] entries (oldest evicted first). */ +private fun List.capped(): List = + if (size <= MAX_COOKIES_PER_HOST) this else takeLast(MAX_COOKIES_PER_HOST) + +/** + * Fold [incoming] into the stored set: same-identity cookies are replaced IN PLACE (position + * preserved), an already-expired incoming cookie deletes its stored twin (the `Max-Age=0` logout + * shape), and anything stored that has expired is dropped on the way through. Returns a NEW list. + */ +private fun List.merged(incoming: List, now: Long): List = + incoming.fold(filter { it.expiresAt > now }) { acc, cookie -> + if (cookie.expiresAt > now) acc.upserting(cookie) else acc.filterNot { it.sameIdentity(cookie) } + } + +private fun List.upserting(cookie: Cookie): List = + if (any { it.sameIdentity(cookie) }) { + map { if (it.sameIdentity(cookie)) cookie else it } + } else { + this + cookie + } + +/** RFC 6265 cookie identity: (name, domain, path). The host partition is applied one level up. */ +private fun Cookie.sameIdentity(other: Cookie): Boolean = + name == other.name && domain == other.domain && path == other.path + +private fun Cookie.toSnapshot(): AuthCookieSnapshot = + AuthCookieSnapshot( + name = name, + value = value, + domain = domain, + path = path, + expiresAtEpochMillis = expiresAt, + secure = secure, + httpOnly = httpOnly, + hostOnly = hostOnly, + ) + +/** + * Rebuild an OkHttp [Cookie] from storage. Values at rest are untrusted (a tampered or + * format-drifted blob) — an unparsable record yields null and is dropped rather than crashing the + * jar. `expiresAt` is set explicitly, so a restored cookie is always persistent. + */ +private fun AuthCookieSnapshot.toCookieOrNull(): Cookie? = + try { + Cookie.Builder() + .name(name) + .value(value) + .expiresAt(expiresAtEpochMillis) + .path(path) + .let { if (hostOnly) it.hostOnlyDomain(domain) else it.domain(domain) } + .let { if (secure) it.secure() else it } + .let { if (httpOnly) it.httpOnly() else it } + .build() + } catch (_: IllegalArgumentException) { + null // never log the exception payload — it can carry the cookie value + } catch (_: IllegalStateException) { + null + } diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt new file mode 100644 index 0000000..350e779 --- /dev/null +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt @@ -0,0 +1,82 @@ +package wang.yaojia.webterm.transport + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * The name of the server's shared-access-token cookie (`src/http/auth.ts` `AUTH_COOKIE_NAME`). + * Exposed as a constant so nothing has to hard-code the string; the jar itself is name-agnostic + * (it stores whatever the paired host sets, scoped to that host). + */ +public const val AUTH_COOKIE_NAME: String = "webterm_auth" + +/** + * Upper bound on how many cookies are retained per host. The paired server sets exactly one + * ([AUTH_COOKIE_NAME]); the cap is a boundary guard so a broken or hostile host cannot turn the + * client into an unbounded credential sink. Oldest entries are evicted first. + */ +internal const val MAX_COOKIES_PER_HOST: Int = 16 + +/** + * A storable, OkHttp-free view of one cookie — the wire between [AuthCookieJar] and whatever + * persists it (`:host-registry`'s `AuthCookieStore`, adapted in `:app`). + * + * The DTO is declared HERE rather than shared with `:host-registry` on purpose: `:transport-okhttp` + * must keep depending only on `:wire-protocol` (+ OkHttp), exactly like the [ClientIdentityProvider] + * seam avoids a `:client-tls` edge. `:app` maps the two field-for-field. + * + * [expiresAtEpochMillis] is ABSOLUTE (wall-clock ms), never a `Max-Age` duration: persisting the raw + * `Set-Cookie` header would silently restart the lifetime on every restore and extend a shell + * credential past what the server granted. + * + * [toString] is REDACTED. [value] is the credential — it must never reach a log, a crash report or a + * `toString()`. + */ +public data class AuthCookieSnapshot( + val name: String, + val value: String, + val domain: String, + val path: String, + val expiresAtEpochMillis: Long, + val secure: Boolean = false, + val httpOnly: Boolean = false, + val hostOnly: Boolean = true, +) { + override fun toString(): String = + "AuthCookieSnapshot(name=$name, value=, domain=$domain, path=$path, " + + "expiresAtEpochMillis=$expiresAtEpochMillis, secure=$secure, httpOnly=$httpOnly, hostOnly=$hostOnly)" +} + +/** + * Where a host's cookie set goes when it changes. Deliberately NON-suspending: OkHttp calls the + * `CookieJar` synchronously on the call thread, so the implementation must hand off (e.g. + * `scope.launch { store.replaceHost(...) }`) rather than block on DataStore I/O. + * + * The callback always carries the host's COMPLETE persistent cookie set, so the implementation is a + * wholesale replace — an empty list means "this host has no persisted cookie any more" (server-side + * deletion, expiry, or unpair), never "nothing changed". + */ +public fun interface AuthCookiePersister { + /** @param hostKey the isolation key from [authCookieHostKey] — never a bare hostname. */ + public fun persist(hostKey: String, cookies: List) + + public companion object { + /** Memory-only: the cookie lives for the process lifetime and is never written down. */ + public val NONE: AuthCookiePersister = AuthCookiePersister { _, _ -> } + } +} + +/** + * The per-host isolation key: `scheme://host:port` with the port ALWAYS explicit + * (`HttpUrl.port` fills in the scheme default). Stricter than the cookie RFC on purpose — browsers + * scope cookies by domain alone, which would hand a host's shell credential to a different service + * on the same machine or to the same name over a different scheme. + * + * The WS upgrade lands on the same key as REST: OkHttp normalises `ws(s)://` to `http(s)://`, and a + * `HostEndpoint`'s `wsUrl` and `baseUrl` share scheme/host/port by construction. + */ +public fun authCookieHostKey(url: HttpUrl): String = "${url.scheme}://${url.host}:${url.port}" + +/** Same key derived from a `HostEndpoint.baseUrl` (or any absolute http(s) URL); null if unparsable. */ +public fun authCookieHostKey(baseUrl: String): String? = + baseUrl.toHttpUrlOrNull()?.let { authCookieHostKey(it) } diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/CleartextGuard.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/CleartextGuard.kt new file mode 100644 index 0000000..897c3ef --- /dev/null +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/CleartextGuard.kt @@ -0,0 +1,91 @@ +package wang.yaojia.webterm.transport + +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import wang.yaojia.webterm.wire.HostClassifier +import wang.yaojia.webterm.wire.HostNetworkTier +import java.net.UnknownServiceException + +/** + * The app refused to send this request **in plaintext** because the target host is not on a network + * where cleartext is allowed (plan §6.9 / §8 "Cleartext posture"). + * + * The supertype is load-bearing, twice over: + * - it is a `java.io.IOException`, so OkHttp reports it through the normal failure path + * (`Callback.onFailure` / `WebSocketListener.onFailure`) and every existing caller already handles + * it — nothing leaves the device before it is thrown; + * - it is specifically [UnknownServiceException], which is EXACTLY what the Android platform throws + * when `network_security_config` blocks cleartext — and what `:api-client`'s + * `PairingError.classify` already maps to `PairingError.CleartextBlocked`. So a refused dial + * surfaces as "this host must use https/wss", not as the misleading "host unreachable" + * (`ConnectException`) — the user can tell "the app refused this" from "the host is down" with no + * changes anywhere else. Do not widen this supertype without re-checking that classifier. + * + * @param host the URL host that was refused (never a credential — safe to show and to log). + * @param tier what [HostClassifier] made of that host (always a non-cleartext tier here). + */ +public class CleartextNotPermittedException( + public val host: String, + public val tier: HostNetworkTier, +) : UnknownServiceException( + "Refused to send an unencrypted request to \"$host\" ($tier): plaintext http/ws is permitted " + + "only for loopback, private-LAN and Tailscale hosts. Use https/wss for this host.", +) + +/** + * The transport-time half of the cleartext allowlist. + * + * Android's `network_security_config` is the platform half, and it genuinely **cannot** express a + * CIDR/prefix — the format takes exact domains only. So the shipped config permits cleartext in + * `` (plus a TLS-only `` for the tunnel apex), and the CIDR scoping plan + * §6.9 asks for is enforced here, where a real classification is possible: + * + * | tier | cleartext (`http`/`ws`) | why | + * |---|---|---| + * | [HostNetworkTier.LOOPBACK] | permitted | on-device only; the hook side-channel and local dev | + * | [HostNetworkTier.PRIVATE_LAN] | permitted | THE bare-LAN posture (RFC1918 · 169.254/16 · `.local`) | + * | [HostNetworkTier.TAILSCALE] | permitted | CGNAT 100.64/10 · `*.ts.net` — already WireGuard-encrypted | + * | [HostNetworkTier.PUBLIC] | **refused** | includes every unclassifiable host — fail closed | + * + * `https`/`wss` is never touched: TLS to a public host is the tunnel/Tailscale path and stays on + * default system trust. + */ +internal object CleartextPolicy { + + /** The tiers whose plaintext dial is allowed. Everything else — including "unknown" — is not. */ + private val CLEARTEXT_TIERS: Set = setOf( + HostNetworkTier.LOOPBACK, + HostNetworkTier.PRIVATE_LAN, + HostNetworkTier.TAILSCALE, + ) + + /** + * The refusal for [url], or null when the dial is allowed. Pure: OkHttp has already normalised + * `ws(s)://` to `http(s)://` by the time a request exists, so [HttpUrl.isHttps] is the whole + * encrypted/not test. + */ + fun refusalFor(url: HttpUrl): CleartextNotPermittedException? { + if (url.isHttps) return null + val tier = HostClassifier.classify(url.host) + if (tier in CLEARTEXT_TIERS) return null + return CleartextNotPermittedException(host = url.host, tier = tier) + } +} + +/** + * Installs [CleartextPolicy] on the one shared client (see [OkHttpClientFactory]). It runs as an + * **application** interceptor — first in the chain, before DNS, before the connection pool, before + * the cookie jar attaches a credential — so a refused request never touches the network. + * + * That placement is safe only because the client also pins `followRedirects(false)` / + * `followSslRedirects(false)`: an application interceptor does not see redirect follow-ups, so a + * `3xx` to `http://` would otherwise slip past this check. The two must stay together. + */ +internal class CleartextGuardInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + CleartextPolicy.refusalFor(request.url)?.let { throw it } + return chain.proceed(request) + } +} diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt index ca64aae..937e739 100644 --- a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt @@ -1,5 +1,6 @@ package wang.yaojia.webterm.transport +import okhttp3.CookieJar import okhttp3.OkHttpClient import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager @@ -48,23 +49,45 @@ public fun interface ClientIdentityProvider { * one client → one `SSLSocketFactory` → mTLS applies uniformly to WS and REST, and the connection * pool is shared. * - * Two invariants baked in here: + * Five invariants baked in here: * - **`.cache(null)`** (plan §8): no ephemeral/disk HTTP cache — preview/diff bodies can hold - * terminal secrets, so nothing is persisted. + * terminal secrets, so nothing is persisted. A [cookieJar] is NOT a cache and never re-enables it. * - **Server-cert trust = default system trust.** Tunnel (`*.terminal.yaojia.wang`) and Tailscale * MagicDNS present real LE certs, so NO custom `X509TrustManager` is installed for the common - * case (plan §6.9). Bare-LAN `ws://` cleartext is an `:app` `network_security_config` allowlist - * concern, NOT this module's — a plain `ws://` upgrade needs no TLS here at all. + * case (plan §6.9). + * - **Cleartext is scoped to LAN/Tailscale/loopback** ([CleartextGuardInterceptor]). The `:app` + * `network_security_config` is only half the control: the format has no CIDR/prefix syntax, so it + * can only permit cleartext globally. The CIDR half of plan §6.9 is enforced here — a plaintext + * `http`/`ws` dial to a PUBLIC host fails fast with [CleartextNotPermittedException] instead of + * silently succeeding. + * - **No redirects followed** (`followRedirects` / `followSslRedirects` = false). No route in plan + * §4.2/§4.3 legitimately redirects, and following one would let a hostile or compromised host + * `3xx` an `https` call down to `http` — past the cleartext guard, which (as an application + * interceptor) does not see redirect follow-ups. Callers get the `3xx` verbatim. + * - **One cookie jar for WS + REST.** The optional `WEBTERM_TOKEN` gate answers pairing with a + * `webterm_auth` cookie that every later HTTP route AND the WS upgrade require. Because both + * transports share this client, installing an [AuthCookieJar] here puts the cookie on the upgrade + * request too (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Default is + * [CookieJar.NO_COOKIES] — a host with no token gate behaves exactly as before. */ public object OkHttpClientFactory { /** * @param identityProvider optional mTLS material; [ClientIdentityProvider.NONE] → default trust, * no client cert. + * @param cookieJar session-cookie store; [CookieJar.NO_COOKIES] disables cookies entirely. */ public fun create( identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE, + cookieJar: CookieJar = CookieJar.NO_COOKIES, ): OkHttpClient { - val builder = OkHttpClient.Builder().cache(null) + val builder = OkHttpClient.Builder() + .cache(null) + .cookieJar(cookieJar) + // First in the chain: refuse a public-host plaintext dial before DNS, the connection pool + // or the cookie jar can act on it. + .addInterceptor(CleartextGuardInterceptor()) + .followRedirects(false) + .followSslRedirects(false) identityProvider.currentIdentity()?.let { identity -> builder.sslSocketFactory(identity.sslSocketFactory, identity.trustManager) } @@ -86,8 +109,9 @@ public class OkHttpTransports private constructor( public companion object { public fun create( identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE, + cookieJar: CookieJar = CookieJar.NO_COOKIES, ): OkHttpTransports { - val client = OkHttpClientFactory.create(identityProvider) + val client = OkHttpClientFactory.create(identityProvider, cookieJar) return OkHttpTransports( term = OkHttpTermTransport(client), http = OkHttpHttpTransport(client), diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt new file mode 100644 index 0000000..f1dea54 --- /dev/null +++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt @@ -0,0 +1,351 @@ +package wang.yaojia.webterm.transport + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +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.BeforeEach +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest + +/** + * The `WEBTERM_TOKEN` session-cookie path (server: `src/http/auth.ts`). The gate sets an + * `HttpOnly; SameSite=Strict; Secure-when-https` cookie named `webterm_auth` at pairing and then + * requires it on EVERY remote HTTP route **and on the WS upgrade** (which is a plain HTTP request — + * without the cookie the socket 401s before it ever opens). + * + * These tests pin four things: + * 1. **replay** — a cookie set by a host rides every later request to that host, REST and WS alike; + * 2. **host isolation** — host A's cookie is NEVER offered to host B (a shell credential; a + * cross-host leak is a security bug, not a cosmetic one); + * 3. **scope/expiry** — expiry and the `Secure` scope are honoured (OkHttp's own `Cookie.matches`), + * and a server deletion (`Max-Age=0`) is propagated to storage; + * 4. **no credential in any log/toString path**. + * + * The `Origin` byte-equality assertion is repeated on the WS upgrade here on purpose: adding the + * `Cookie` header must not disturb THE CSWSH defence. + */ +class AuthCookieJarTest { + + private lateinit var server: MockWebServer + private lateinit var otherServer: MockWebServer + private val persister = RecordingPersister() + private var nowOffsetMs: Long = 0L + private val jar = AuthCookieJar(persister = persister, clock = { System.currentTimeMillis() + nowOffsetMs }) + private lateinit var client: OkHttpClient + + @BeforeEach + fun setUp() { + server = MockWebServer().apply { start() } + otherServer = MockWebServer().apply { start() } + client = OkHttpClientFactory.create(cookieJar = jar) + } + + @AfterEach + fun tearDown() { + client.dispatcher.cancelAll() + client.connectionPool.evictAll() + runCatching { server.shutdown() } + runCatching { otherServer.shutdown() } + client.dispatcher.executorService.shutdown() + } + + private fun transport() = OkHttpHttpTransport(client) + + private fun MockWebServer.get(path: String) = runBlocking { + transport().send(HttpRequest(HttpMethod.GET, url(path).toString())) + } + + private fun setCookieResponse(attributes: String = "Path=/; Max-Age=$MAX_AGE_SEC; HttpOnly; SameSite=Strict") = + MockResponse() + .setResponseCode(200) + .addHeader("Set-Cookie", "$AUTH_COOKIE_NAME=$TOKEN; $attributes") + .setBody("{}") + + // ── 1. replay ──────────────────────────────────────────────────────────────── + + @Test + fun replaysTheAuthCookieOnEveryLaterRequestToTheSameHost() { + // Arrange: pairing response carries the Set-Cookie; two later reads follow. + server.enqueue(setCookieResponse()) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + server.get("/?token=$TOKEN") + server.get("/live-sessions") + server.get("/config/ui") + + // Assert: the pairing request itself had no cookie; both later ones did. + assertNull(server.takeRequest().getHeader("Cookie")) + assertEquals("$AUTH_COOKIE_NAME=$TOKEN", server.takeRequest().getHeader("Cookie")) + assertEquals("$AUTH_COOKIE_NAME=$TOKEN", server.takeRequest().getHeader("Cookie")) + } + + @Test + fun carriesTheCookieOnTheWsUpgradeWithTheOriginStillByteEqual() = runBlocking { + // Arrange: pair over REST (cookie captured), then dial the WS on the SAME host. + server.enqueue(setCookieResponse()) + val serverWs = OpenedServerWebSocket() + server.enqueue(MockResponse().withWebSocketUpgrade(serverWs)) + server.get("/?token=$TOKEN") + server.takeRequest() // drop the pairing request + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}")) + + // Act + val connection = withTimeout(TIMEOUT_MS) { OkHttpTermTransport(client).connect(endpoint) } + + // Assert: the UPGRADE request carries the cookie AND the byte-equal Origin (CSWSH defence). + val upgrade = server.takeRequest() + assertEquals("$AUTH_COOKIE_NAME=$TOKEN", upgrade.getHeader("Cookie")) + assertEquals(endpoint.originHeader, upgrade.getHeader("Origin")) + connection.close() + } + + @Test + fun restoredCookiesAreReplayedAfterAColdStart() { + // Arrange: a fresh process — nothing in memory, everything rehydrated from storage. + val coldJar = AuthCookieJar(clock = { System.currentTimeMillis() + nowOffsetMs }) + val coldClient = OkHttpClientFactory.create(cookieJar = coldJar) + val hostKey = requireNotNull(authCookieHostKey(server.url("/").toString())) + coldJar.restore(hostKey, listOf(snapshot(expiresAtEpochMillis = System.currentTimeMillis() + HOUR_MS))) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + runBlocking { + OkHttpHttpTransport(coldClient) + .send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString())) + } + + // Assert + assertEquals("$AUTH_COOKIE_NAME=$TOKEN", server.takeRequest().getHeader("Cookie")) + coldClient.connectionPool.evictAll() + coldClient.dispatcher.executorService.shutdown() + } + + // ── 2. host isolation ──────────────────────────────────────────────────────── + + @Test + fun neverSendsAHostsCookieToADifferentHost() { + // Arrange: host A hands out the credential; host B must never see it. + server.enqueue(setCookieResponse()) + otherServer.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + server.get("/?token=$TOKEN") + otherServer.get("/live-sessions") + + // Assert + val leaked = otherServer.takeRequest().getHeader("Cookie") + assertNull(leaked, "host A's shell credential must never ride a request to host B") + } + + @Test + fun clearForgetsOneHostsCookiesAndLeavesTheOthersAlone() { + // Arrange: both hosts are authed. + server.enqueue(setCookieResponse()) + otherServer.enqueue(setCookieResponse()) + server.get("/?token=$TOKEN") + otherServer.get("/?token=$TOKEN") + server.takeRequest() + otherServer.takeRequest() + + // Act: unpair host A only. + jar.clear(requireNotNull(authCookieHostKey(server.url("/").toString()))) + + // Assert + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + otherServer.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + server.get("/live-sessions") + otherServer.get("/live-sessions") + assertNull(server.takeRequest().getHeader("Cookie")) + assertEquals("$AUTH_COOKIE_NAME=$TOKEN", otherServer.takeRequest().getHeader("Cookie")) + } + + // ── 3. scope + expiry ──────────────────────────────────────────────────────── + + @Test + fun stopsReplayingAnExpiredCookieAndPersistsTheRemoval() { + // Arrange + server.enqueue(setCookieResponse()) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + server.get("/?token=$TOKEN") + server.takeRequest() + + // Act: jump past Max-Age. + nowOffsetMs = (MAX_AGE_SEC + 1) * 1_000L + server.get("/live-sessions") + + // Assert: not sent any more, and storage was told to drop it. + assertNull(server.takeRequest().getHeader("Cookie")) + assertEquals(emptyList(), persister.latestFor(hostKey())) + } + + @Test + fun honoursAServerSideDeletionOfTheCookie() { + // Arrange: authed, then the server expires the cookie (Max-Age=0 — the logout shape). + server.enqueue(setCookieResponse()) + server.enqueue(setCookieResponse(attributes = "Path=/; Max-Age=0")) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + server.get("/?token=$TOKEN") + server.get("/live-sessions") + server.get("/live-sessions") + + // Assert + server.takeRequest() + server.takeRequest() + assertNull(server.takeRequest().getHeader("Cookie")) + assertEquals(emptyList(), persister.latestFor(hostKey())) + } + + @Test + fun doesNotRestoreACookieThatAlreadyExpiredAtRest() { + // Arrange: storage handed back a stale record (clock skew / long-backgrounded app). + val coldJar = AuthCookieJar(clock = { System.currentTimeMillis() + nowOffsetMs }) + val coldClient = OkHttpClientFactory.create(cookieJar = coldJar) + coldJar.restore(hostKey(), listOf(snapshot(expiresAtEpochMillis = System.currentTimeMillis() - 1))) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + runBlocking { + OkHttpHttpTransport(coldClient) + .send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString())) + } + + // Assert + assertNull(server.takeRequest().getHeader("Cookie")) + coldClient.connectionPool.evictAll() + coldClient.dispatcher.executorService.shutdown() + } + + @Test + fun aSecureCookieIsNeverSentOverCleartext() { + // Arrange: a Secure cookie restored for a cleartext LAN host (the bare-LAN ws:// posture). + jar.restore( + hostKey(), + listOf(snapshot(expiresAtEpochMillis = System.currentTimeMillis() + HOUR_MS, secure = true)), + ) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + server.get("/live-sessions") + + // Assert + assertNull(server.takeRequest().getHeader("Cookie")) + } + + @Test + fun persistsOnlyTheHostThatIssuedTheCookieAndOnlyPersistentCookies() { + // Arrange: one persistent (Max-Age) + one session cookie (no Max-Age/Expires). + server.enqueue( + MockResponse() + .setResponseCode(200) + .addHeader("Set-Cookie", "$AUTH_COOKIE_NAME=$TOKEN; Path=/; Max-Age=$MAX_AGE_SEC") + .addHeader("Set-Cookie", "transient=x; Path=/") + .setBody("{}"), + ) + + // Act + server.get("/?token=$TOKEN") + + // Assert: storage is told about the persistent one only (a session cookie must not outlive + // the process), and it is filed under this host's key. + val persisted = persister.latestFor(hostKey()) + assertEquals(listOf(AUTH_COOKIE_NAME), persisted?.map { it.name }) + assertEquals(TOKEN, persisted?.single()?.value) + } + + @Test + fun capsTheNumberOfCookiesStoredPerHost() { + // Arrange: a hostile/broken server tries to make the client an unbounded cookie sink. + val response = MockResponse().setResponseCode(200).setBody("{}") + repeat(MAX_COOKIES_PER_HOST * 2) { i -> response.addHeader("Set-Cookie", "c$i=v$i; Path=/; Max-Age=$MAX_AGE_SEC") } + server.enqueue(response) + + // Act + server.get("/") + + // Assert + assertEquals(MAX_COOKIES_PER_HOST, persister.latestFor(hostKey())?.size) + } + + // ── 4. the credential never reaches a log/toString path ────────────────────── + + @Test + fun neverExposesTheCookieValueInAnyToStringPath() { + // Arrange + server.enqueue(setCookieResponse()) + server.get("/?token=$TOKEN") + + // Act + val rendered = listOf( + jar.toString(), + persister.latestFor(hostKey()).toString(), + persister.latestFor(hostKey())!!.single().toString(), + AuthCookieJar().toString(), + ) + + // Assert: the token/cookie value appears nowhere; the name/host key may. + rendered.forEach { text -> + assertFalse(text.contains(TOKEN), "credential leaked into a log/toString path: $text") + } + assertTrue(rendered[2].contains(AUTH_COOKIE_NAME), "the redacted rendering should still identify the cookie") + } + + private fun hostKey(): String = requireNotNull(authCookieHostKey(server.url("/").toString())) + + private fun snapshot( + expiresAtEpochMillis: Long, + secure: Boolean = false, + ) = AuthCookieSnapshot( + name = AUTH_COOKIE_NAME, + value = TOKEN, + domain = server.hostName, + path = "/", + expiresAtEpochMillis = expiresAtEpochMillis, + secure = secure, + httpOnly = true, + hostOnly = true, + ) + + private companion object { + const val TOKEN = "s3cr3t-webterm-token-value" + const val MAX_AGE_SEC = 60L + const val HOUR_MS = 3_600_000L + const val TIMEOUT_MS = 5_000L + } +} + +/** Records the last persisted snapshot per host key — the `:app`/DataStore side of the seam. */ +private class RecordingPersister : AuthCookiePersister { + private val latest = mutableMapOf>() + + override fun persist(hostKey: String, cookies: List) { + latest[hostKey] = cookies + } + + fun latestFor(hostKey: String): List? = latest[hostKey] +} + +/** Minimal server-side WS listener: only "the upgrade happened" matters here. */ +private class OpenedServerWebSocket : WebSocketListener() { + val opened = CompletableDeferred() + + override fun onOpen(webSocket: WebSocket, response: Response) { + opened.complete(webSocket) + } +} diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/CleartextGuardTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/CleartextGuardTest.kt new file mode 100644 index 0000000..5524903 --- /dev/null +++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/CleartextGuardTest.kt @@ -0,0 +1,304 @@ +package wang.yaojia.webterm.transport + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import okhttp3.Dns +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +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.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HostNetworkTier +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import java.io.IOException +import java.net.InetAddress +import java.net.UnknownServiceException + +/** + * Transport-time cleartext scoping (plan §6.9 / §8 "Cleartext posture"). + * + * Android's `network_security_config` has no CIDR/prefix syntax, so the platform file can only permit + * cleartext globally (plus a TLS-only `` for the tunnel apex). The CIDR allowlist the + * platform cannot express is enforced HERE, on the one shared client: a plaintext (`http`/`ws`) dial + * is allowed only for loopback / private-LAN / Tailscale hosts + * ([wang.yaojia.webterm.wire.HostClassifier]); a plaintext dial to a PUBLIC host fails fast with the + * typed [CleartextNotPermittedException] before a byte leaves the device. + * + * Two layers of test, on purpose: + * - **wiring** — real [MockWebServer] exchanges through the real client. A non-loopback URL *host* + * reaches the loopback server via a pinned [Dns], so the guard sees the host it would see in the + * field and "refused" is proven by `requestCount == 0`. (Only hostnames can be pinned this way: + * OkHttp resolves an IP literal itself and never consults `Dns`.) + * - **the decision table** — [CleartextPolicy] over every CIDR edge, including the ones no local + * socket can stand in for (10/8, 172.16/12, CGNAT …). + */ +class CleartextGuardTest { + + private lateinit var server: MockWebServer + + @BeforeEach + fun setUp() { + server = MockWebServer().apply { start() } + } + + @AfterEach + fun tearDown() { + runCatching { server.shutdown() } + } + + // ── wiring: permitted tiers reach the host ────────────────────────────────────────────────── + + @Test + fun permitsCleartextToAPrivateLanHost() { + // Arrange: the bare-LAN posture — http:// to an mDNS LAN name (PRIVATE_LAN). + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + val response = get("http://mybox.local:${server.port}/live-sessions") + + // Assert: the dial went through to the host. + assertEquals(200, response.status) + assertEquals(1, server.requestCount) + } + + @Test + fun permitsCleartextToATailscaleMagicDnsHost() { + // Arrange: *.ts.net is already WireGuard-encrypted — cleartext there must keep working. + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + val response = get("http://mybox.tailnet-1a2b.ts.net:${server.port}/live-sessions") + + // Assert + assertEquals(200, response.status) + assertEquals(1, server.requestCount) + } + + @Test + fun permitsCleartextToLoopbackSoLocalFlowsKeepWorking() { + // Arrange + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act: the plain MockWebServer URL (localhost/127.0.0.1) — no DNS pinning, no guard in the way. + val response = runBlocking { + OkHttpHttpTransport(OkHttpClientFactory.create()) + .send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString())) + } + + // Assert + assertEquals(200, response.status) + assertEquals(1, server.requestCount) + } + + // ── wiring: cleartext to a public host is refused ─────────────────────────────────────────── + + @Test + fun refusesCleartextToAPublicHostBeforeAnyByteLeavesTheDevice() { + // Arrange: a public name that would otherwise resolve straight to the live server. + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act: caught as IOException on purpose — the refusal MUST be one, or OkHttp's async path + // cannot surface it as a call failure. + val failure = assertThrows { + get("http://terminal.example.com:${server.port}/live-sessions") + } + + // Assert: typed, self-describing, and nothing was sent. + val refusal = assertInstanceOf(CleartextNotPermittedException::class.java, failure) + assertEquals("terminal.example.com", refusal.host) + assertEquals(HostNetworkTier.PUBLIC, refusal.tier) + assertTrue( + refusal.message.orEmpty().contains("terminal.example.com"), + "the refusal must name the host it refused: ${refusal.message}", + ) + assertEquals(0, server.requestCount, "a refused cleartext dial must never reach the network") + } + + @Test + fun theRefusalIsTheSameTypeThePlatformUsesSoTheExistingPairingCopyApplies() { + // `:api-client`'s PairingError.classify maps java.net.UnknownServiceException — the platform's + // own network_security_config cleartext block — to PairingError.CleartextBlocked ("this host + // must use https/wss"). Our refusal must land in that bucket, NOT in HostUnreachable, or the + // user cannot tell "the app refused this" from "the host is down". + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + val failure = assertThrows { + get("http://terminal.example.com:${server.port}/live-sessions") + } + + assertInstanceOf(CleartextNotPermittedException::class.java, failure) + } + + @Test + fun refusesACleartextWebSocketUpgradeToAPublicHost() { + // Arrange: the terminal stream itself — the plaintext path that matters most. + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://terminal.example.com:${server.port}")) + server.enqueue(MockResponse().withWebSocketUpgrade(NeverReachedServerWebSocket())) + + // Act + val failure = assertThrows { + runBlocking { OkHttpTermTransport(pinnedToLoopback()).connect(endpoint) } + } + + // Assert + assertEquals("terminal.example.com", failure.host) + assertEquals(0, server.requestCount, "the ws:// upgrade must be refused before the socket opens") + } + + @Test + fun refusesCleartextToAnUnclassifiableHostFailSafe() { + // Arrange: an out-of-range dotted quad is not an IP at all — HostClassifier fails safe to + // PUBLIC, so must the guard. + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + Assert + assertThrows { + get("http://999.1.2.3:${server.port}/live-sessions") + } + assertEquals(0, server.requestCount) + } + + // ── wiring: https is untouched ────────────────────────────────────────────────────────────── + + @Test + fun leavesHttpsToAPublicHostAlone() { + // Arrange: the tunnel/Tailscale shape. The MockWebServer speaks cleartext, so the handshake + // will fail — what matters is HOW it fails: at the socket, not at our guard. + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + val failure = assertThrows { + get("https://terminal.example.com:${server.port}/live-sessions") + } + + // Assert: the guard did NOT veto it — the call got as far as TLS. + assertFalse( + failure is CleartextNotPermittedException, + "https must never be refused by the cleartext guard, got: $failure", + ) + } + + // ── wiring: no silent scheme downgrade ────────────────────────────────────────────────────── + + @Test + fun doesNotFollowRedirectsSoAHostCannotDowngradeTheClient() { + // Arrange: a host answers with a 3xx pointing at a public http:// URL. + server.enqueue( + MockResponse() + .setResponseCode(302) + .addHeader("Location", "http://terminal.example.com:${server.port}/live-sessions"), + ) + + // Act + val response = get("http://mybox.local:${server.port}/live-sessions") + + // Assert: the 3xx is handed to the caller verbatim; the client chased nothing. + assertEquals(302, response.status) + assertEquals(1, server.requestCount) + } + + @Test + fun theSharedClientRefusesToFollowRedirectsAtAll() { + val client = OkHttpClientFactory.create() + assertFalse(client.followRedirects, "no plan §4.2/§4.3 route legitimately redirects") + assertFalse(client.followSslRedirects, "an https→http downgrade must never be chased") + } + + // ── the decision table (every CIDR edge, incl. ones no local socket can stand in for) ──────── + + @Test + fun permitsCleartextForEveryLoopbackPrivateAndTailscaleHost() { + val allowed = listOf( + "127.0.0.1", "127.1.2.3", "localhost", // loopback + "10.0.0.5", "10.255.255.255", // RFC1918 10/8 + "172.16.0.1", "172.31.255.254", // RFC1918 172.16/12 + "192.168.1.50", // RFC1918 192.168/16 + "169.254.7.7", // link-local + "mybox.local", "MyBox.Local", // mDNS (case-insensitive) + "100.64.0.1", "100.127.255.254", // CGNAT / Tailscale + "mybox.tailnet-1a2b.ts.net", // MagicDNS + ) + + allowed.forEach { host -> + assertNull( + CleartextPolicy.refusalFor("http://$host:3000/live-sessions".toHttpUrl()), + "cleartext to $host is a supported path and must not be refused", + ) + } + } + + @Test + fun refusesCleartextForPublicAndOutOfRangeHosts() { + val refused = mapOf( + "8.8.8.8" to HostNetworkTier.PUBLIC, // plainly public + "172.15.0.1" to HostNetworkTier.PUBLIC, // just below RFC1918 172.16/12 + "172.32.0.1" to HostNetworkTier.PUBLIC, // just above it + "100.63.255.255" to HostNetworkTier.PUBLIC, // just below CGNAT + "100.128.0.1" to HostNetworkTier.PUBLIC, // just above it + "192.169.1.1" to HostNetworkTier.PUBLIC, // near-miss on 192.168/16 + "169.253.7.7" to HostNetworkTier.PUBLIC, // near-miss on link-local + "terminal.yaojia.wang" to HostNetworkTier.PUBLIC, // the tunnel — TLS-only + "ts.net.attacker.example" to HostNetworkTier.PUBLIC, // suffix spoof + "999.1.2.3" to HostNetworkTier.PUBLIC, // unclassifiable → fail closed + ) + + refused.forEach { (host, tier) -> + val refusal = CleartextPolicy.refusalFor("http://$host:3000/live-sessions".toHttpUrl()) + assertEquals(host, refusal?.host, "cleartext to $host must be refused") + assertEquals(tier, refusal?.tier) + } + } + + @Test + fun neverRefusesAnEncryptedDialWhateverTheTier() { + listOf("terminal.yaojia.wang", "8.8.8.8", "192.168.1.50", "mybox.tailnet-1a2b.ts.net").forEach { host -> + assertNull( + CleartextPolicy.refusalFor("https://$host/live-sessions".toHttpUrl()), + "https is out of the guard's scope — $host must pass through", + ) + } + } + + // ── helpers ───────────────────────────────────────────────────────────────────────────────── + + /** + * A client whose DNS always answers loopback, so a non-loopback URL host reaches [server]. + * Hostnames only — OkHttp parses an IP literal itself and never calls [Dns]. + */ + private fun pinnedToLoopback(): OkHttpClient = + OkHttpClientFactory.create() + .newBuilder() + .dns(LoopbackDns) + .build() + + private fun get(url: String) = runBlocking { + OkHttpHttpTransport(pinnedToLoopback()).send(HttpRequest(HttpMethod.GET, url)) + } +} + +/** Resolves every hostname to loopback (the MockWebServer's bind address). */ +private object LoopbackDns : Dns { + override fun lookup(hostname: String): List = listOf(InetAddress.getLoopbackAddress()) +} + +/** Minimal server-side WS listener; [opened] must stay incomplete — the upgrade is refused. */ +private class NeverReachedServerWebSocket : WebSocketListener() { + val opened = CompletableDeferred() + + override fun onOpen(webSocket: WebSocket, response: Response) { + opened.complete(webSocket) + } +} diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt index a9ba58c..8b34958 100644 --- a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt +++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt @@ -1,5 +1,6 @@ package wang.yaojia.webterm.transport +import okhttp3.CookieJar import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame @@ -36,7 +37,11 @@ class OkHttpClientFactoryTest { .socketFactory // Act - val client = OkHttpClientFactory.create { ClientIdentity(sslSocketFactory, trustManager) } + // (named argument: `create` now also takes a trailing `cookieJar`, so a trailing lambda + // would bind to the wrong parameter.) + val client = OkHttpClientFactory.create( + identityProvider = { ClientIdentity(sslSocketFactory, trustManager) }, + ) // Assert: the shared client uses exactly the injected factory (mTLS applies to WS + REST). assertSame(sslSocketFactory, client.sslSocketFactory) @@ -50,6 +55,27 @@ class OkHttpClientFactoryTest { assertNull(transports.client.cache) } + @Test + fun defaultClientCarriesNoCookieJar() { + // The gate is opt-in: an unconfigured client behaves exactly as before (LAN zero-config). + assertSame(CookieJar.NO_COOKIES, OkHttpClientFactory.create().cookieJar) + } + + @Test + fun injectedCookieJarIsWiredWithoutEnablingTheHttpCache() { + // Arrange + val jar = AuthCookieJar() + + // Act + val client = OkHttpClientFactory.create(cookieJar = jar) + + // Assert: the jar reaches BOTH transports through the one shared client, and a cookie jar + // is NOT a cache — `.cache(null)` (plan §8) stays intact. + assertSame(jar, client.cookieJar) + assertNull(client.cache, "a cookie jar must never re-enable the HTTP cache") + assertSame(jar, OkHttpTransports.create(cookieJar = jar).client.cookieJar) + } + private fun systemDefaultTrustManager(): X509TrustManager { val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) factory.init(null as KeyStore?) diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ApprovalPreview.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ApprovalPreview.kt new file mode 100644 index 0000000..93317f3 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ApprovalPreview.kt @@ -0,0 +1,81 @@ +package wang.yaojia.webterm.wire + +/** + * W1 — a bounded preview of the tool a held approval is asking about + * (`src/types.ts` `ApprovalPreview`, carried on `status.preview`). + * + * ### Why this type has to exist + * Without it a remote approval is **blind**: the gate surfaces could show the tool's *name* and nothing + * else, so the user was being asked to approve a `Bash` command without seeing the command, or an `Edit` + * without seeing the diff. Approving blind is worse than not being able to approve at all — the whole + * point of the remote gate is that the person holding the phone can make an informed call. The server has + * been sending this since W1; Android simply dropped it on the floor. + * + * ### Untrusted, and rendered inert + * Both variants carry attacker-influenced text: a command line and a diff body come from whatever Claude + * Code decided to run against whatever the repo contains. Renderers MUST treat every string here as inert + * (plan §8) — plain text only, no linkification, no Markdown, no autolink. The server already strips + * control/ANSI characters from [Command.text] except newlines, but this client does not depend on that. + * + * @see [ServerMessage.Status.preview] + */ +public sealed interface ApprovalPreview { + + /** The source exceeded the server's line/byte cap and was clipped, so what is shown is incomplete. */ + public val truncated: Boolean + + /** + * A shell command (the `Bash` tool family). Newlines are preserved by the server; every other + * control/ANSI character is stripped before it reaches us. + */ + public data class Command( + val text: String, + override val truncated: Boolean = false, + ) : ApprovalPreview + + /** + * ONE synthetic file diff (the `Edit` / `Write` / `MultiEdit` / `NotebookEdit` family). + * + * The shape mirrors the server's `DiffFile`, but note the deliberate boundary: `:app` owns the full + * diff *viewer* (its own richer model lives with `DiffViewModel`, which is fed by `GET /projects/diff` + * and needs staging/base-mode state this type has no business carrying). This is the read-only subset + * a gate needs in order to answer "what am I approving?", and it lives here because `:wire-protocol` + * is the single place new wire types may be added (CLAUDE.md) and `:session-core` may not depend on + * `:app`. + */ + public data class Diff( + val file: PreviewDiffFile, + override val truncated: Boolean = false, + ) : ApprovalPreview +} + +/** One file's worth of diff inside an [ApprovalPreview.Diff] (`src/types.ts` `DiffFile`). */ +public data class PreviewDiffFile( + val oldPath: String, + val newPath: String, + /** Raw wire value; unknown values are preserved verbatim rather than coerced, so a new server + * status renders as itself instead of silently becoming "modified". */ + val status: String, + val added: Int, + val removed: Int, + val binary: Boolean, + val hunks: List, +) + +/** One hunk (`src/types.ts` `DiffHunk`). */ +public data class PreviewDiffHunk( + val header: String, + val lines: List, +) + +/** + * One diff line (`src/types.ts` `DiffLine`). + * + * [kind] stays a raw wire string for the same reason as [PreviewDiffFile.status]: a gate that mis-colours + * an unknown line kind is a cosmetic bug, but a gate that DROPS a line it cannot classify would hide part + * of what the user is approving. + */ +public data class PreviewDiffLine( + val kind: String, + val text: String, +) diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt index c6ee40b..09d435f 100644 --- a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt @@ -112,6 +112,7 @@ public object MessageCodec { ServerMessageType.EXIT -> decodeExit(root) ServerMessageType.STATUS -> decodeStatus(root) ServerMessageType.TELEMETRY -> decodeTelemetry(root) + ServerMessageType.QUEUE -> decodeQueue(root) null -> null } } @@ -132,7 +133,71 @@ public object MessageCodec { val detail = obj.stringField("detail") val pending = obj.boolField("pending") ?: false val gate = obj.stringField("gate")?.let { GateKind.fromWire(it) } - return ServerMessage.Status(status, detail, pending, gate) + // A malformed preview degrades to null and NEVER fails the frame: the `pending` signal is what + // makes the gate appear at all, so trading a missing preview for a missing gate would be a bad bargain. + val preview = decodePreview(obj["preview"]) + return ServerMessage.Status(status, detail, pending, gate, preview) + } + + private fun decodeQueue(obj: JsonObject): ServerMessage? { + val length = obj.intField("length") ?: return null + // A negative depth is nonsense the UI would render as a badge; drop the frame rather than + // propagate it. The next broadcast carries the real depth, so nothing is lost for long. + if (length < 0) return null + return ServerMessage.Queue(length) + } + + /** + * `status.preview` (W1). Returns null for absent, non-object, unknown-`kind`, or structurally broken + * input — every one of which is a preview we cannot show, not a frame we should discard. + */ + private fun decodePreview(element: kotlinx.serialization.json.JsonElement?): ApprovalPreview? { + val obj = element as? JsonObject ?: return null + val truncated = obj.boolField("truncated") ?: false + return when (obj.stringField("kind")) { + PREVIEW_KIND_COMMAND -> obj.stringField("text")?.let { + ApprovalPreview.Command(text = it, truncated = truncated) + } + PREVIEW_KIND_DIFF -> decodePreviewFile(obj["file"])?.let { + ApprovalPreview.Diff(file = it, truncated = truncated) + } + else -> null + } + } + + private fun decodePreviewFile(element: kotlinx.serialization.json.JsonElement?): PreviewDiffFile? { + val obj = element as? JsonObject ?: return null + // Paths identify WHAT is being changed; without them the preview cannot be trusted to describe + // the right file, so this is the one place the diff variant is dropped rather than degraded. + val oldPath = obj.stringField("oldPath") ?: return null + val newPath = obj.stringField("newPath") ?: return null + return PreviewDiffFile( + oldPath = oldPath, + newPath = newPath, + status = obj.stringField("status") ?: "", + added = obj.intField("added") ?: 0, + removed = obj.intField("removed") ?: 0, + binary = obj.boolField("binary") ?: false, + hunks = (obj["hunks"] as? kotlinx.serialization.json.JsonArray) + ?.mapNotNull { decodePreviewHunk(it) } + .orEmpty(), + ) + } + + private fun decodePreviewHunk(element: kotlinx.serialization.json.JsonElement?): PreviewDiffHunk? { + val obj = element as? JsonObject ?: return null + return PreviewDiffHunk( + header = obj.stringField("header") ?: "", + // Drop-one-keep-rest: a single unparseable line must not cost the user the rest of the diff + // they are being asked to approve. + lines = (obj["lines"] as? kotlinx.serialization.json.JsonArray) + ?.mapNotNull { line -> + val l = line as? JsonObject ?: return@mapNotNull null + val text = l.stringField("text") ?: return@mapNotNull null + PreviewDiffLine(kind = l.stringField("kind") ?: "", text = text) + } + .orEmpty(), + ) } private fun decodeTelemetry(obj: JsonObject): ServerMessage? { @@ -229,4 +294,8 @@ public object MessageCodec { key: String, deserializer: kotlinx.serialization.DeserializationStrategy, ): T? = this[key]?.let { runCatching { json.decodeFromJsonElement(deserializer, it) }.getOrNull() } + + /** `ApprovalPreview` discriminators (`src/types.ts` — a string union, not an enum). */ + private const val PREVIEW_KIND_COMMAND = "command" + private const val PREVIEW_KIND_DIFF = "diff" } diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt index 689efc4..fdb7504 100644 --- a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt @@ -36,10 +36,27 @@ public sealed interface ServerMessage { val detail: String? = null, val pending: Boolean = false, val gate: GateKind? = null, + /** + * W1 — what the held approval is actually asking about. Present only on a held ([pending]) + * waiting status for a Bash/Edit-family tool; null otherwise, which is a NORMAL state and not an + * error (plenty of gates have nothing previewable). A malformed preview also decodes to null + * rather than dropping the whole frame: losing the preview degrades the UI, but losing the frame + * would lose the `pending` signal itself and the gate would never appear. + */ + val preview: ApprovalPreview? = null, ) : ServerMessage /** Latest statusLine telemetry broadcast (B2). */ public data class Telemetry(val telemetry: StatusTelemetry) : ServerMessage + + /** + * W2 — current pending-inject queue depth, broadcast to EVERY attached device so all mirrors show the + * same "N queued" badge (`src/types.ts` `{ type: 'queue'; length: number }`). + * + * Previously this frame had no member here at all, so `decodeServer` returned null for it and + * `SessionEngine` silently discarded every one — the badge could never appear on Android. + */ + public data class Queue(val length: Int) : ServerMessage } /** @@ -77,7 +94,8 @@ public enum class ServerMessageType(public val wire: String) { OUTPUT("output"), EXIT("exit"), STATUS("status"), - TELEMETRY("telemetry"); + TELEMETRY("telemetry"), + QUEUE("queue"); public companion object { public fun fromWire(value: String): ServerMessageType? = entries.firstOrNull { it.wire == value } diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ApprovalPreviewDecodeTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ApprovalPreviewDecodeTest.kt new file mode 100644 index 0000000..6c5da63 --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ApprovalPreviewDecodeTest.kt @@ -0,0 +1,156 @@ +package wang.yaojia.webterm.wire + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * W1 `status.preview` + W2 `queue` decode. + * + * Both of these were silently lost before: `queue` had no `ServerMessageType` member so the whole frame + * was dropped as undecodable, and `preview` was not modelled at all, which made every remote approval + * BLIND — the user was asked to approve a `Bash` call without seeing the command. + * + * The governing rule these tests pin: a broken PREVIEW must never cost us the FRAME. The `pending` flag on + * a status frame is what makes the gate appear; trading a missing preview for a missing gate would be a + * bad bargain, so every malformed-preview case below still yields a `Status`. + */ +@DisplayName("MessageCodec — status.preview (W1) and queue (W2)") +class ApprovalPreviewDecodeTest { + + private fun status(previewJson: String): ServerMessage.Status? = + MessageCodec.decodeServer( + """{"type":"status","status":"working","pending":true,"gate":"tool","detail":"Bash","preview":$previewJson}""", + ) as? ServerMessage.Status + + // ── queue (W2) ─────────────────────────────────────────────────────────────────────────────────── + + @Test + @DisplayName("a queue frame decodes instead of being dropped as unknown") + fun queueDecodes() { + val msg = MessageCodec.decodeServer("""{"type":"queue","length":3}""") + assertEquals(ServerMessage.Queue(3), msg) + } + + @Test + @DisplayName("queue depth zero is a real value, not an absent one") + fun queueZero() { + assertEquals(ServerMessage.Queue(0), MessageCodec.decodeServer("""{"type":"queue","length":0}""")) + } + + @Test + @DisplayName("a negative or missing depth drops the frame rather than feeding nonsense to a badge") + fun queueRejectsNonsense() { + assertNull(MessageCodec.decodeServer("""{"type":"queue","length":-1}""")) + assertNull(MessageCodec.decodeServer("""{"type":"queue"}""")) + assertNull(MessageCodec.decodeServer("""{"type":"queue","length":"3"}"""), "a quoted number is not a number") + } + + // ── preview: command variant ───────────────────────────────────────────────────────────────────── + + @Test + @DisplayName("a command preview decodes, newlines preserved") + fun commandPreview() { + val s = status("""{"kind":"command","text":"rm -rf build\ncd ..","truncated":true}""") + assertEquals( + ApprovalPreview.Command(text = "rm -rf build\ncd ..", truncated = true), + s?.preview, + ) + } + + @Test + @DisplayName("truncated defaults to false when the server omits it") + fun truncatedDefaults() { + val p = status("""{"kind":"command","text":"ls"}""")?.preview + assertEquals(ApprovalPreview.Command("ls", truncated = false), p) + } + + // ── preview: diff variant ──────────────────────────────────────────────────────────────────────── + + @Test + @DisplayName("a diff preview decodes file, hunks and lines") + fun diffPreview() { + val s = status( + """{"kind":"diff","file":{"oldPath":"a.kt","newPath":"a.kt","status":"modified", + "added":2,"removed":1,"binary":false, + "hunks":[{"header":"@@ -1 +1,2 @@","lines":[ + {"kind":"add","text":"new"},{"kind":"del","text":"old"}]}]}}""", + ) + val diff = s?.preview as? ApprovalPreview.Diff + assertNotNull(diff, "the diff variant must decode") + assertEquals("a.kt", diff!!.file.newPath) + assertEquals(2, diff.file.added) + assertEquals(1, diff.file.hunks.size) + assertEquals( + listOf(PreviewDiffLine("add", "new"), PreviewDiffLine("del", "old")), + diff.file.hunks.single().lines, + ) + } + + @Test + @DisplayName("an unknown line kind is kept, not dropped — hiding a line hides what is being approved") + fun unknownLineKindKept() { + val s = status( + """{"kind":"diff","file":{"oldPath":"a","newPath":"a","status":"totally-new-status", + "added":0,"removed":0,"binary":false, + "hunks":[{"header":"h","lines":[{"kind":"future-kind","text":"keep me"}]}]}}""", + ) + val diff = s?.preview as ApprovalPreview.Diff + assertEquals("totally-new-status", diff.file.status, "an unknown status renders as itself") + assertEquals( + listOf(PreviewDiffLine("future-kind", "keep me")), + diff.file.hunks.single().lines, + ) + } + + @Test + @DisplayName("one unparseable line is dropped but its siblings survive") + fun lossyLines() { + val s = status( + """{"kind":"diff","file":{"oldPath":"a","newPath":"a","status":"modified", + "added":0,"removed":0,"binary":false, + "hunks":[{"header":"h","lines":[ + {"kind":"add","text":"first"},{"kind":"add"},{"kind":"add","text":"third"}]}]}}""", + ) + val diff = s?.preview as ApprovalPreview.Diff + assertEquals( + listOf("first", "third"), + diff.file.hunks.single().lines.map { it.text }, + "drop-one-keep-rest: a malformed line must not cost the user the rest of the diff", + ) + } + + // ── degradation: the load-bearing rule ─────────────────────────────────────────────────────────── + + @Test + @DisplayName("every malformed preview degrades to null AND still yields the Status frame") + fun malformedPreviewNeverCostsTheFrame() { + val broken = listOf( + """null""" to "explicit null", + """"a string"""" to "not an object", + """{}""" to "no kind", + """{"kind":"command"}""" to "command without text", + """{"kind":"diff"}""" to "diff without file", + """{"kind":"diff","file":{"newPath":"a"}}""" to "diff file without oldPath", + """{"kind":"video"}""" to "unknown kind", + ) + broken.forEach { (json, why) -> + val s = status(json) + assertNotNull(s, "the frame must survive a bad preview ($why)") + assertNull(s!!.preview, "the preview must degrade to null ($why)") + assertTrue(s.pending, "the pending flag — the reason the gate appears at all — must survive ($why)") + } + } + + @Test + @DisplayName("a status frame with no preview at all is a normal frame") + fun previewAbsentIsNormal() { + val s = MessageCodec.decodeServer("""{"type":"status","status":"waiting","pending":true}""") + as? ServerMessage.Status + assertNotNull(s) + assertNull(s!!.preview) + } +} diff --git a/docs/ANDROID_CLIENT_PLAN.md b/docs/ANDROID_CLIENT_PLAN.md index 5768ecc..d9aaabc 100644 --- a/docs/ANDROID_CLIENT_PLAN.md +++ b/docs/ANDROID_CLIENT_PLAN.md @@ -74,7 +74,7 @@ Reach functional parity with the iOS client's shipped feature set (P0 + P1). Eve - **`DELETE /live-sessions` kill-all / a "manage" page** — **iOS does NOT consume this route**; its swipe-to-kill and pointer-menu kill both use per-session `DELETE /live-sessions/:id`. Out of parity; do not build a kill-all surface iOS lacks. - **On-device WebView/xterm.js fidelity fallback** — only if Termux emulation fails QA (§6.8). Not built by default. - **System-`KeyChain` MDM cert path** — documented fallback only; wrong trust model for a per-device pinned identity. -- **Self-signed-LAN `wss://` custom trust / pinning** — bare LAN uses `ws://` cleartext (allowlisted CIDRs); `wss` is only for the tunnel/Tailscale, which present real CA-signed certs. A `CertificatePinner`/custom `X509TrustManager` path is documented but not built (§6.9, §8). +- **Self-signed-LAN `wss://` custom trust / pinning** — bare LAN uses `ws://` cleartext (permitted in `base-config`; see §6.9 — the platform has no CIDR syntax, so it CANNOT be scoped to RFC1918); `wss` is only for the tunnel/Tailscale, which present real CA-signed certs. A `CertificatePinner`/custom `X509TrustManager` path is documented but not built (§6.9, §8). - **Cross-device palette sync** — matches current design (iOS/web/Android stores are independent). - **Voice commands / editor-open / worktree-create** — server has routes (`/open-in-editor`, `/projects/worktree`) the iOS client does not consume; out of parity scope. @@ -96,7 +96,7 @@ Reach functional parity with the iOS client's shipped feature set (P0 + P1). Eve | Persistence (secret / device cert) | **Google Tink AEAD + AndroidKeystore master key**; **the private key is imported non-exportable into `AndroidKeyStore`** (the runtime home). Tink AEAD encrypts only the **cert chain + metadata** blob at rest. **No `.p12` blob or passphrase is persisted.** | Non-exportable, device-bound = the `…AfterFirstUnlockThisDeviceOnly` analogue. **Not `EncryptedFile`/`security-crypto`** (that Jetpack Security API is deprecated — draft inconsistency resolved). | | Push | **Firebase Cloud Messaging (FCM) v1**, data-only high-priority | Only way to reproduce the lock-screen two-tap Allow/Deny loop (§4.5, §9 R1). | | mTLS | **`AndroidKeyStore`-imported non-exportable key + a custom re-reading `X509KeyManager`** (returns the AndroidKeyStore `PrivateKey`/chain per handshake) → `SSLContext` → OkHttp `sslSocketFactory`. **`KeyStore("PKCS12")` is used transiently only to *parse the import file*, never as the runtime key home.** | App-private identity (NOT system `KeyChain`); re-reading KeyManager + `connectionPool.evictAll()` gives the iOS "no-relaunch cert rotation" behavior. One key home, no contradiction (R4/mustFix). | -| Server-cert trust | **Default system trust** (tunnel `*.terminal.yaojia.wang` + Tailscale MagicDNS present real LE certs). Bare LAN uses **`ws://` cleartext** via a `network_security_config` CIDR allowlist. | Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. | +| Server-cert trust | **Default system trust** (tunnel `*.terminal.yaojia.wang` + Tailscale MagicDNS present real LE certs). Bare LAN uses **`ws://` cleartext**, permitted in `network_security_config`'s `base-config` — the format has NO address-range syntax, so it cannot be narrowed to LAN literals (§6.9). | Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. | | QR scan | **CameraX + ML Kit Barcode** (`com.google.mlkit:barcode-scanning`) | On-device, one validator shared with manual entry. | | Biometric | **`androidx.biometric:BiometricPrompt`** hosted in a `FragmentActivity` (the Allow trampoline) | Gates the Allow action. | | Testing | **JUnit5 + kotlinx-coroutines-test (virtual time) + Turbine + MockK** (unit); **Espresso + Compose UI Test** (instrumented; Robolectric only where it faithfully emulates); **Kover** (coverage) | Mirrors iOS's injected-clock/fake-transport discipline. `AndroidKeyStore`/Tink tests are **instrumented on a real emulator/device** — Robolectric does not provide the AndroidKeyStore provider (platform review). | @@ -429,7 +429,20 @@ Two tiers, chosen by decision gate. **Note the blast radius:** the tier-2 WebVie OkHttp requires an `(SSLSocketFactory, X509TrustManager)` pair. Decision: - **Tunnel (`*.terminal.yaojia.wang`) + Tailscale MagicDNS:** real CA-signed (LE) certs → **default system trust**. No custom trust manager. -- **Bare LAN:** use **`ws://` cleartext**, permitted only for private LAN/Tailscale CIDRs via `network_security_config` (global `usesCleartextTraffic=false`). +- **Bare LAN:** use **`ws://` cleartext**, permitted in `network_security_config`'s `base-config`. + **CORRECTED (was wrong in this plan until the A19 fix):** the original text said cleartext would be + "permitted only for private LAN/Tailscale CIDRs via `network_security_config`". That is not + expressible. The network-security-config format has NO netmask/prefix/CIDR attribute anywhere — a + `` holds one hostname or one IP literal, and `includeSubdomains` walks DNS labels, so it cannot + generalise `192.168.0.0/16`. Enumerating ~18.9M addresses is not an option either. Since a LAN address + is user-typed at runtime and this file is a build-time resource, it also cannot be scoped to "the IP the + user actually entered". So: cleartext is ON in `base-config`, `*.terminal.yaojia.wang` keeps an explicit + `cleartextTrafficPermitted="false"` block (the one hostname known at build time), trust anchors are + pinned to `system` only, and the real guard moves into the app — the §5.4 warning tiers plus + confirm-before-network. The scoping the platform cannot express is additionally enforced at + transport time in `OkHttpClientFactory`, which refuses a plaintext dial to a non-private host. + Honest tradeoff: on bare `ws://` the terminal bytes and the `WEBTERM_TOKEN` cookie are readable and + replayable by anyone on the path. This is a LAN/Tailscale posture, never an internet-exposure one. - **Self-signed LAN `wss`:** DEFERRED — a documented `CertificatePinner`/custom `X509TrustManager` path, not built by default. --- @@ -468,7 +481,7 @@ Gesture/IME/CJK composition, camera-QR, haptics, lock-screen Allow/Deny, FCM end - [ ] **mTLS trust model (one key home)** — private key **imported non-exportable into `AndroidKeyStore`**; a **custom re-reading `X509KeyManager`** returns the AndroidKeyStore `PrivateKey`/chain per handshake; `KeyStore("PKCS12")` is import-parse-only (transient), never the runtime home; **NOT** system `KeyChain`. Client cert presented on both WS and REST via one shared `OkHttpClient`. On rotation, `connectionPool.evictAll()` so pooled/resumed connections drop the old identity. - [ ] **Cert storage** — private key non-exportable/device-bound; only the **cert chain + metadata** blob is stored, encrypted with **Tink AEAD + AndroidKeystore master key**; app-private, uninstall-wiped, never in backups/cloud. **No `.p12` blob or passphrase persisted.** Import validates before persisting so a bad passphrase can't clobber a prior identity. - [ ] **No system-wide credential leakage** — no ephemeral/disk HTTP cache (`OkHttpClient.cache(null)`) — preview/diff bodies can contain terminal secrets. -- [ ] **Cleartext posture** — `usesCleartextTraffic=false` globally; a `network_security_config` allowlist permits `ws://` only for LAN/Tailscale CIDRs; `wss` (tunnel/Tailscale) uses default system trust; §5.4 warning tiers reproduced in-app (public-host blocking needs explicit acknowledge; tunnel host = cert-gated, warning softened, TLS failure re-mapped to "client cert invalid/revoked"). +- [x] **Cleartext posture** — cleartext permitted in `base-config` (NO CIDR syntax exists — see §6.9), `*.terminal.yaojia.wang` explicitly denied, trust anchors `system`-only, no `debug-overrides`, and a transport-time private-host check in `OkHttpClientFactory`; locked by `NetworkSecurityConfigTest`; `wss` (tunnel/Tailscale) uses default system trust; §5.4 warning tiers reproduced in-app (public-host blocking needs explicit acknowledge; tunnel host = cert-gated, warning softened, TLS failure re-mapped to "client cert invalid/revoked"). - [ ] **Tunnel-host cert gate** — `runProbe` refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed; both confirm and retry funnel through the one choke point (retry can't bypass). - [ ] **Push token discipline** — single-use `/hook/decision` capability token validated as v4 UUID, passed only to the POST, **never persisted, never logged, never in fallback copy**; payload minimized (`sessionId`/`cls`/`token` only). - [ ] **Notification-action trust split** — **Deny → `BroadcastReceiver`** (`goAsync()`, no UI, expedited POST). **Allow → a translucent, `excludeFromRecents` trampoline `Activity`** that hosts `BiometricPrompt` then POSTs — a `BroadcastReceiver`/`Service` cannot present `BiometricPrompt` and `goAsync()`'s ~10s budget would blow. (Corrects the draft's "no app open" claim for Allow.) All `PendingIntent`s `FLAG_IMMUTABLE` (API 31+). Deny is auth-free (fail-safe). The decision POST is **expedited** (not deferrable `WorkManager`); the single-use token makes a retried-after-success POST return 403 → idempotent retry-safety. diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 99e9565..7626f7e 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,37 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### 🤖 [x] Android 客户端"能装但不能用"修复 — 7 个真实缺陷已修,设备上 67 个 instrumented 测试全绿(2026-07-30,worktree `android-blocker-fixes`) + +- **起因**: 例行问"android 客户端完成状况如何"。`android/PROGRESS_ANDROID.md` 写着 **"✅ ANDROID CLIENT COMPLETE — all 36 plan tasks landed"**,617 个 JVM 测试全绿,APK 也出得来。**这个"完成"只在编译层面成立** —— 从来没在任何真机或模拟器上跑过,而一旦跑,第一个动作就崩。审计(45 agent,含逐条对抗验证)+ 修复(多波 agent)已把 blocker 清掉。 +- **为什么 JVM 测试全绿也漏掉了**: 一个结构性原因 —— **没有任何 JVM 测试会实例化 Android `View`**。`:terminal-view` 那 17 个测试测的是纯逻辑,渲染/输入/触摸整条路径无人触碰。 +- **三个让 App 在真机上不可用的缺陷**(每一条都反编译 `terminal-view-v0.118.0.aar` 逐偏移确认): + 1. **按任何键必崩**。`RemoteTerminalView` 只绑了 emulator,从没调用 `setTerminalViewClient` —— KDoc 写着"A17 会装",A17 实际只交付了 `HardwareKeyRouter` 和 KeyBar(而 KeyBar 之所以能用,恰恰因为它绕过了 view)。stock `TerminalView` 在 `onKeyDown` offset 82、`onCreateInputConnection` offset 0、`onKeyUp`、`onKeyPreIme` 无判空解引用 `mClient`。**装个 client 还不够**:返回 false 会继续走到 offset 150 的 `mTermSession.write()`,而 `mTermSession` 永远是 null(`TerminalSession` 是 final,而 fork 进程正是本 App 绝不能做的事,§6.1)。修法:client 自己消费按键,且 `KeyRouting` **刻意没有"交还 stock"这个分支** —— 崩溃变成不可表达,而不只是被绕开。键表仍由 Termux `KeyHandler` 决定,DECCKM 照旧发 `ESC O A`。 + 2. **resize 从来不发**。`RemoteTerminalSession.updateSize` 零调用点,stock 兜底也是死的(`TerminalView.updateSize` 无 session 时 offset 18-25 直接 return),PTY 一直停在服务端的 80×24,所有全屏 TUI 都排版进错误的框。`TerminalResizeDriver` 现在从真实字距驱动 —— 字距走**公开** accessor `TerminalRenderer.getFontWidth()`/`getFontLineSpacing()`(agent 自己 `javap` 发现它们是 public),**不用反射**(R8 下会静默失效)也**不重测 Paint**(会与渲染器实际所用漂移,正是 §R5 风险)。 + 3. **局域网连不上**。`network_security_config.xml` 是自己标着 STUB 的全局禁明文,而 `HostEndpoint` 接受 `http` 并派生 `ws://`。它注释里承诺的 CIDR 白名单**平台根本不支持**(NSC 格式没有任何 netmask/prefix 属性,`` 只装一个主机名或一个 IP 字面量)。改为 base-config 放开 + `*.terminal.yaojia.wang` 显式禁明文 + 信任锚钉死 system + `OkHttpClientFactory` 在传输层拒绝向非私有地址发起明文 —— 平台表达不了的收窄,放到 HTTP 客户端里做。plan §6.9/§8 的错误描述已一并更正。 +- **对抗评审又挖出三个,其中一条评审自己判错了**: + - **在 alternate-screen TUI 里滑动滚屏仍会崩**。触摸路径完全绕过 `TerminalViewClient`:`doScroll` 在 alternate buffer 激活时把滚动转成 `handleKeyCode`,而那里解引用 `mTermSession`。**Claude Code 本身就是 alternate-screen TUI**,所以这是主界面正常使用中的崩溃。`TerminalScrollGesture` 抢占纵向拖动并复现 `doScroll` 的三个分支,连 fling runnable 和 `onGenericMotionEvent` 一并封死。 + - **`autofill()` 无判空解引用 `mTermSession`**,而该 view 自称可自动填充 → 任何密码管理器一填就崩。整个子树已排除出 autofill 结构。 + - 评审要求**恢复** stock 文字选择(此前被 focus 改造弄成不可达)。**这条它错了**:ActionMode 自己的 Copy/Paste 处理器也解引用空 session(`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136),恢复可达性只会把崩溃搬到用户点 Copy 那一刻 —— 比没有选择功能更糟。**长按保持被消费,复制功能如实记为功能缺口**(真正的修法是基于 `TerminalBuffer` + `ClipboardManager` 自建选择层)。 +- **被静默丢弃的服务端数据,现已解码**: W2 `queue` 帧在 `ServerMessageType` 里没有成员,`decodeServer` 返回 null,`SessionEngine` 当"无法解码"丢掉 —— "N queued"徽标在 Android 上**从来不可能出现**;W1 `status.preview` 完全没建模,导致远程审批是**盲批**(只看到工具名,看不到要执行的命令/diff)。两者均已解码。一条规矩用测试钉住:**preview 坏掉绝不能连帧一起丢** —— `pending` 才是让 gate 出现的信号。 +- **当作功能发布的死代码**: `ThumbnailPipeline`(会话列表预览)、`QuickReply`(芯片+调色板)、`PointerContextMenu` 三者实现完整、单测齐全、**生产代码零调用点**。均已接线。`ThumbnailPipeline` 当年被**明确跳过**的交叉评审(AW3 条目自己承认)这次补做了。 +- **凭据处理**: `WEBTERM_TOKEN` cookie 现在在 REST 和 WS upgrade 上都携带,用 Tink AEAD + AndroidKeyStore 封装,**fail-closed**(封装失败就不持久化,绝不退回明文);`allowBackup=false` 阻止这个 30 天 shell 凭据经 Google Drive / D2D 外泄。 +- **v0.6 git 面板数据层**: 服务端 8fe1f52 之后的字段 Android 全没解码。已补 `SyncState`/`dirtyCount`/`cwd`/`unpushed`/`upstream` + `GET /projects/worktree/state` + `POST /projects/git/fetch`。两条易错语义**编码成行为**而非靠调用点自觉:`SyncState.isFullySynced(now)` 是唯一被认可的"能否显示绿色"判断(任何未知即 false,**无 upstream ≠ 0**);`GitLogResult.upstreamBoundaryIndex` 找**最后一个**未推送提交(取前 N 行会朝危险方向出错)。 +- **验证**: `./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify` —— blocker 修复提交时 **757 个 JVM 测试全绿**(基线 617);`:wire-protocol`/`:session-core`/`:api-client` 三处 koverVerify 均过。commit `3e5cfdc`、`3fe719f`、`35d32f4`。 +- **方法与其代价(如实记录)**: 分波多 agent(TDD builder → 对抗评审 → 修复)。**API 端持续 529 过载,13 个 agent 里 8 个、11 个里 9 个被打掉**;协议层和 REST 层两轮全灭后由 orchestrator 亲自补写。这也是为什么会话成本冲到 ~$360。对抗评审确实值 —— 滑动滚屏那个 CRITICAL 崩溃是 builder 漏掉、reviewer 抓到的。 +- **仍未完成(不要把上面读成已完成)**: + - **A34 / A35 完全没有代码** —— `:app` 连 androidTest 源集都没有,也没有 benchmark 模块。AW6 条目把它们标成 `[x]` 是因为它们被转成了清单条目,不是实现。 + - **设备 QA 仍约 0%** —— 仓库里唯一的真机证据是一张配对页的模拟器截图。上面每一条修复都只有 JVM 级验证。 + - release 签名/版本号/minify;S2 FCM 真机投递;复制功能。 + - `android/PROGRESS_ANDROID.md` 顶部已加"CORRECTION"段落说明上述全部。 +- **后续同日完成(本条目最初写的"仍未完成"已不成立)**: + - **设备上真跑起来了**: `:app/src/androidTest/**` 67 个 instrumented 测试 + `macrobenchmark/`,在 `emulator-5554` 上对本仓库自己的服务器**全绿(67/0)**。含四个崩溃回归、A34 E2E(attach→output、ring buffer 重放、用第二个故意配坏的服务器测 spawn 失败、`POST /hook/decision` 解 gate、**F9 坏 Origin 拒绝**)、以及 plan §7 点名的 Compose 测试。模拟器要打通宿主机必须 `ALLOWED_ORIGINS=http://10.0.2.2:` —— 服务端的 allowedOrigins 从网卡 IP 派生,而 10.0.2.2 是模拟器对宿主机的别名、不是任何网卡地址。 + - **设备上又抓出第四个崩溃**: `onDraw` → `TerminalRow.getStyle` 的 `AIOOBE`,而 `PROGRESS_ANDROID.md` 原本把它写成"Accepted (not a defect) — 撕裂读会自我修正"。两半都是错的:它是进程直杀;而且"matches upstream Termux"也不成立 —— 上游 `append` 由 `MainThreadHandler` 在主线程调用,压根没有并发读者。根因是 `TerminalEmulator.resize` **先**发布新 `mColumns`、**后**才重新分配行数组,`length == index` 那个"可疑的一致性"是**扩容的确定性签名**。**而且这个竞态以前是休眠的** —— resize 从不发生,列数就不变,没有宽度差可撕;是本次把 resize 修好才让它变可达。修法:emulator mutation 全部挪到渲染线程(与上游一致),单写者模型不变。已先复现成 JVM 测试,所以这条回归以后不需要设备就能守住。 + - **审计遗留 6 项 + 复制功能全部关闭**: 证书静默轮换(顺带修掉一个会让**每次续期都失败**的 `renew()` 解码 bug,并指出 iOS 有个"只轮换一次"的潜在缺陷 Android 通过从叶证书反推 `renewAfter` 避开了)、`/device/:id/recover`(走非 mTLS,因为过期证书没法给自己的恢复做认证)、W2 inject-queue 三条路由、未读水位持久化、host 移除 UI、`/config/ui` 被尊重且**fail-closed**、以及基于 `TerminalBuffer.getSelectedText` 自建的选择/复制层(stock ActionMode 的 Copy 处理器解引用空 session,所以不能恢复它)。 + - **最终门禁**: `./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :macrobenchmark:assembleBenchmark koverVerify` → BUILD SUCCESSFUL,**1150 JVM 测试 / 0 失败**(基线 617);instrumented **67/0**。commit `3e5cfdc`→`390dd11` 共 9 个。 +- **仍然开放**: **S2**(FCM 真机投递,需要 ≥2 台物理设备在 Doze/厂商省电下验证,模拟器答不了);`DEVICE_QA_CHECKLIST.md` 里没有自动化覆盖的其余设备可观测项;一个 instrumented 测试在**不传 host 参数**且模拟器被反复安装卸载后会杀掉测试进程,按文档化的调用方式+干净安装则稳定通过 —— 上真机再判。 +- **本次合并时发现的新 parity 缺口(记录,未做)**: `develop` 在本分支之后新增了 **孤儿 tmux session** 功能 —— `OrphanSessionInfo` 类型 + 5 条路由(`GET /orphan-sessions`、`/count-idle`、`/:id/preview`、`DELETE /orphan-sessions`、`/:id`)。**Android 客户端完全不知道它们**。服务端刻意把它做成独立类型而非 `LiveSessionInfo` 上的 flag,所以现有解码器不受影响、不会解错 —— 这是一个新增缺口而不是回归。iOS 同样没有。 + ### 🗂 [x] A · 让服务器"看不见"的 tmux session 重新可达(2026-07-30,worktree `orphan-tmux-sessions`) - **起因**: 用户问"关掉桌面 App 会不会把 tmux session 一起关掉"。查证过程中发现一个**功能缺口**:`tmux ls` 有 **69 个** `web_*` session,只有 **5 个**有 client —— 另外 64 个**从 UI 完全不可达**,其中一个还跑着 7 个 agent 的 Claude Code。最老的到 6 月 26 日。