feat(android): close the audit's remaining parity gaps

Seven items the earlier repair waves deliberately left alone, so that the
blocker fixes could land with a working safety net first.

CERTIFICATE LIFECYCLE (the audit's only "high"). iOS ships a
CertificateRotationScheduler; Android had no counterpart, so a device
certificate simply expired and needed a manual re-enroll. The decision is a
pure total function over five answers, with RE_ENROLL_REQUIRED checked first so
it outranks any backoff. Renewal is single-flight — a second trigger coalesces,
and a cancelled leader releases the slot so cancellation cannot wedge rotation
for the process lifetime. Failure leaves the prior identity fully live (the
existing atomic ping-pong flip already guaranteed that) and is published rather
than swallowed.

Two real defects surfaced while building it:

  renew() decoded its 201 with EnrollResponseDto, whose deviceId is required —
  but /device/:id/renew returns only {cert, caChain, notAfter}; only
  /device/enroll echoes deviceId. Every silent renewal would have thrown
  MalformedResponse. Fixed with a RenewResponseDto plus the deviceId from the
  path segment we addressed.

  And because no renew 201 carries renewAfter, a client that persisted it would
  rotate exactly once and then report not-due until the cert died. That is why
  renewAfter is re-derived from the live leaf as notBefore + 2/3·lifetime — the
  control plane's own formula. This is a latent iOS defect that Android now
  avoids rather than inherits.

/device/:id/recover was confirmed real (control-plane/src/api/renew.ts:443,
contract pinned by that suite's CP6e) and is now wired. It goes over a separate
NON-mTLS client that throws rather than falling back, because an expired client
certificate cannot authenticate its own recovery — the deadlock a previous
session already hit and recorded.

Asked explicitly about step-up: rotation is unaffected. stepUp appears nowhere
in control-plane/src; it lives only on the relay's WebSocket upgrade. On a
step-up host the SESSION is denied, not the rotation, so the certificate still
stays alive and that principal gap is orthogonal.

FOLLOW-UP QUEUE (W2). The queue frame decoded but the three routes managing it
were missing, so Android could see "N queued" and not queue anything. One
outcome union covers both guarded writes and distinguishes full / too-large /
disabled / session-gone / rate-limited, because they need different copy. 429 is
surfaced and never auto-retried — POST and DELETE share one 20/min bucket.
Queued text is raw shell input and travels verbatim, proven with control
characters and CJK.

UNREAD WATERMARK now persists, so the unread state survives process death —
which is exactly when it matters, since the product premise is walking away and
coming back. It stores a plain map rather than an UnreadLedger: the reducer
lives in :session-core, which :host-registry may not depend on, so the logic is
not restated anywhere.

COPY-OUT. Text selection was a recorded feature gap: the stock ActionMode's own
Copy handler dereferences the absent TerminalSession, so making it reachable
puts an NPE on the button — worse than no selection. The refusal was
re-verified from the bytecode, and the same disassembly showed the way out:
TerminalBuffer.getSelectedText touches only mLines/TerminalRow, no session and
no view. A first-party selection layer now builds on that, with a pure
row-major model so a backwards drag normalises correctly. Terminal content
reaches the clipboard only on an explicit user copy; OSC 52 stays declined.

HOST REMOVAL. PushRegistrar.unregisterHost had zero call sites, so a removed
host kept receiving pushes. Removal is now confirm-gated and cleans everything
keyed by host id — a half-removed host is worse than none.

/config/ui is finally honoured. It was implemented and never called, so
allowAutoMode was ignored and the plan gate offered "approve + auto" even where
the operator had disabled it. It fails CLOSED: an unfetchable config treats auto
as disabled, because the permissive default is the dangerous one.

Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 1150 JVM
tests, 0 failures (903 -> 1150). Instrumented suite re-run on emulator-5554
against live servers: 67 tests, 0 failures, 0 skipped — no device regression.
This commit is contained in:
Yaojia Wang
2026-07-30 15:37:09 +02:00
parent 8075d2c671
commit 390dd11202
70 changed files with 8096 additions and 141 deletions

View File

@@ -15,8 +15,11 @@ import wang.yaojia.webterm.wire.HttpTransport
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain, * `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain, * deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
* notBefore, notAfter, renewAfter }` * notBefore, notAfter, renewAfter }`
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The * `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 `{ cert, caChain,
* server schema is `.strict()`; NO keyAlg/subdomain/deviceName. * 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 * 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 * sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
@@ -29,8 +32,12 @@ public class DeviceEnrollmentClient(
baseUrl: String, baseUrl: String,
private val http: HttpTransport, 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 * 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 path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken)) 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 ──────────────────────────────────────────────────────────── // ── 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 // Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
// string must never emit a bare "Bearer " header. // string must never emit a bare "Bearer " header.
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer" 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` /** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
@@ -127,6 +165,24 @@ public class DeviceEnrollmentClient(
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse .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 { private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
val chain = dto.caChain.map { entry -> val chain = dto.caChain.map { entry ->

View File

@@ -97,6 +97,19 @@ internal data class EnrollRequestBody(
@Serializable @Serializable
internal data class RenewRequestBody(val csr: String) 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 @Serializable
internal data class LoginResponseDto( internal data class LoginResponseDto(
val enrollToken: String, val enrollToken: String,
@@ -114,6 +127,25 @@ internal data class EnrollResponseDto(
val renewAfter: String? = null, 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<String> = emptyList(),
val notBefore: String? = null,
val notAfter: String? = null,
val renewAfter: String? = null,
)
@Serializable @Serializable
internal data class ErrorDto(val error: String? = null) internal data class ErrorDto(val error: String? = null)

View File

@@ -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
}
}

View File

@@ -42,4 +42,9 @@ public data class LiveSessionInfo(
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive /** 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". */ * optional field — pre-P1 servers omit it; `null` means "no unread data source". */
val lastOutputAt: Long? = null, 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,
) )

View File

@@ -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<String> = 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

View File

@@ -2,6 +2,8 @@ package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor
@@ -9,7 +11,9 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import wang.yaojia.webterm.wire.ClaudeStatus import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID import java.util.UUID
@@ -36,6 +40,23 @@ internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire) 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<Int?> {
private val delegate: KSerializer<Int?> = 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<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the * A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`, * Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,

View File

@@ -11,6 +11,9 @@ import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult 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.FetchResult
import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult
@@ -137,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 /** `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). */ * `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs { public suspend fun prefs(): UiPrefs {
@@ -182,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 ────────────── // ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */ /** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */

View File

@@ -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. */ /** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。") 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. */ /** 500 from `GET /projects/log` — the server failed reading the git log. */
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。") public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")

View File

@@ -12,8 +12,11 @@ internal object HttpStatus {
const val BAD_REQUEST = 400 const val BAD_REQUEST = 400
const val FORBIDDEN = 403 const val FORBIDDEN = 403
const val NOT_FOUND = 404 const val NOT_FOUND = 404
const val CONFLICT = 409
const val PAYLOAD_TOO_LARGE = 413
const val TOO_MANY_REQUESTS = 429 const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500 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. */ /** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
const val CLIENT_ERROR_MIN = 400 const val CLIENT_ERROR_MIN = 400

View File

@@ -12,10 +12,10 @@ import java.util.UUID
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token** * `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
* pair (this client uses FCM, not APNs/VAPID). * pair (this client uses FCM, not APNs/VAPID).
* *
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui` * RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue`
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs` * · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs` * G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue`
* · `POST|DELETE /push/fcm-token` * · `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 * 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` + * of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
@@ -70,6 +70,13 @@ internal object Endpoints {
fun getPrefs(): ApiRoute = fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY) 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). */ /** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
fun projectPr(path: String): ApiRoute = fun projectPr(path: String): ApiRoute =
ApiRoute( ApiRoute(
@@ -175,6 +182,36 @@ internal object Endpoints {
fun gitPush(path: String): ApiRoute = fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path)) 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]). */ /** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
private fun <T> jsonBodyRoute( private fun <T> jsonBodyRoute(
method: HttpMethod, method: HttpMethod,
@@ -242,4 +279,7 @@ internal object Endpoints {
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
private data class FetchBody(val path: String) private data class FetchBody(val path: String)
@Serializable
private data class EnqueueBody(val text: String, val appendEnter: Boolean)
} }

View File

@@ -253,6 +253,137 @@ class DeviceEnrollmentClientTest {
assertTrue(transport.recordedRequests.isEmpty()) 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 ────────────────────────────────────────────────────────────────────── // ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
@Test @Test

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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) })
}
}

View File

@@ -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"))
}
}

View File

@@ -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
}
}
}

View File

@@ -113,6 +113,10 @@ private fun TitleLine(row: SessionRow) {
) { ) {
StatusBadge(status = row.displayStatus) StatusBadge(status = row.displayStatus)
if (row.isUnread) UnreadDot() 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(
text = row.title.ifBlank { fallbackLabel(row.info) }, text = row.title.ifBlank { fallbackLabel(row.info) },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -202,7 +206,12 @@ private fun SessionListRowPreview() {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
SessionListRow( SessionListRow(
row = SessionRow( 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, displayStatus = DisplayStatus.Working,
title = "web-terminal", title = "web-terminal",
isUnread = true, isUnread = true,
@@ -228,6 +237,7 @@ private fun previewInfo(
cols: Int = 161, cols: Int = 161,
rows: Int = 50, rows: Int = 50,
lastOutputAt: Long? = null, lastOutputAt: Long? = null,
queueLength: Int? = null,
): LiveSessionInfo = LiveSessionInfo( ): LiveSessionInfo = LiveSessionInfo(
id = UUID.fromString("11111111-2222-4333-8444-555555555555"), id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
createdAt = 1L, createdAt = 1L,
@@ -240,4 +250,5 @@ private fun previewInfo(
rows = rows, rows = rows,
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L), telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
lastOutputAt = lastOutputAt, lastOutputAt = lastOutputAt,
queueLength = queueLength,
) )

View File

@@ -1,11 +1,19 @@
package wang.yaojia.webterm.di package wang.yaojia.webterm.di
import com.google.firebase.messaging.FirebaseMessaging
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent 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.PushRegistrarTokenSink
import wang.yaojia.webterm.push.PushTokenSink 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 * Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with
@@ -13,10 +21,52 @@ import wang.yaojia.webterm.push.PushTokenSink
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)` * `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar] * — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
* (self-heal). This is the intended optional-collaborator handoff (no mutable global). * (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 @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
public interface PushModule { public interface PushModule {
@Binds @Binds
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink 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) }
}
}
}
} }

View File

@@ -12,15 +12,22 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.hostregistry.DataStoreHostStore import wang.yaojia.webterm.hostregistry.DataStoreHostStore
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
import wang.yaojia.webterm.hostregistry.DataStoreSessionWatermarkStore
import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.LastSessionStore import wang.yaojia.webterm.hostregistry.LastSessionStore
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore. * 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 * The host list ([HostStore], key `"hosts"`), the per-host last-session id ([LastSessionStore], key
* `"lastSessionId.<hostId>"`) use disjoint keys, so they safely share one DataStore file. Secret * `"lastSessionId.<hostId>"`) and the unread watermarks ([SessionWatermarkStore], key
* material (the device cert) never lives here — that is the Tink store in [TlsModule]. * `"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 @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -43,5 +50,16 @@ public object StorageModule {
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore = public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
DataStoreLastSessionStore(dataStore) 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<Preferences>,
): SessionWatermarkStore = DataStoreSessionWatermarkStore(dataStore)
private const val DATASTORE_NAME: String = "webterm" private const val DATASTORE_NAME: String = "webterm"
} }

View File

@@ -16,6 +16,8 @@ import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.tlsandroid.TinkCertStore import wang.yaojia.webterm.tlsandroid.TinkCertStore
import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wiring.CertificateRotationScheduler
import javax.inject.Singleton import javax.inject.Singleton
/** /**
@@ -78,4 +80,35 @@ public object TlsModule {
@Singleton @Singleton
public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher = public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher =
repository 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<OkHttpClient>,
httpTransport: dagger.Lazy<HttpTransport>,
): CertificateRotationScheduler = CertificateRotationScheduler.create(
certStore = certStore,
recordStore = recordStore,
cacheRefresher = cacheRefresher,
sharedClient = { client.get() },
mtlsTransport = { httpTransport.get() },
)
} }

View File

@@ -47,6 +47,7 @@ import wang.yaojia.webterm.viewmodels.CertPhase
import wang.yaojia.webterm.viewmodels.CertSummaryView import wang.yaojia.webterm.viewmodels.CertSummaryView
import wang.yaojia.webterm.viewmodels.ClientCertUiState import wang.yaojia.webterm.viewmodels.ClientCertUiState
import wang.yaojia.webterm.viewmodels.ClientCertViewModel import wang.yaojia.webterm.viewmodels.ClientCertViewModel
import wang.yaojia.webterm.wiring.RotationOutcome
/** /**
* # ClientCertScreen (A27) — device-certificate (mTLS) management. * # ClientCertScreen (A27) — device-certificate (mTLS) management.
@@ -78,6 +79,7 @@ public fun ClientCertScreen(
viewModel: ClientCertViewModel, viewModel: ClientCertViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null, onBack: (() -> Unit)? = null,
rotationOutcome: RotationOutcome? = rememberRotationOutcome(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
// Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load. // Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load.
@@ -117,9 +119,21 @@ public fun ClientCertScreen(
onDismissError = viewModel::clearError, onDismissError = viewModel::clearError,
modifier = modifier, modifier = modifier,
onBack = onBack, 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. */ /** Stateless body — pure inputs so a [Preview] and any host can drive it without the SAF/VM machinery. */
@Composable @Composable
public fun ClientCertScreen( public fun ClientCertScreen(
@@ -132,6 +146,7 @@ public fun ClientCertScreen(
onDismissError: () -> Unit, onDismissError: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null, onBack: (() -> Unit)? = null,
rotationOutcome: RotationOutcome? = null,
) { ) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column( Column(
@@ -159,6 +174,10 @@ public fun ClientCertScreen(
state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) } 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( PassphraseField(
passphrase = passphrase, passphrase = passphrase,
onPassphraseChange = onPassphraseChange, 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 @Composable
private fun SummaryRow(label: String, value: String) { private fun SummaryRow(label: String, value: String) {
Row( Row(

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -37,11 +39,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle 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 kotlinx.coroutines.launch
import wang.yaojia.webterm.components.PointerContextMenu import wang.yaojia.webterm.components.PointerContextMenu
import wang.yaojia.webterm.components.SessionListRow import wang.yaojia.webterm.components.SessionListRow
@@ -51,9 +58,12 @@ import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.nav.LayoutMode import wang.yaojia.webterm.nav.LayoutMode
import wang.yaojia.webterm.viewmodels.HostOption import wang.yaojia.webterm.viewmodels.HostOption
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
import wang.yaojia.webterm.viewmodels.SessionListUiState import wang.yaojia.webterm.viewmodels.SessionListUiState
import wang.yaojia.webterm.viewmodels.SessionListViewModel import wang.yaojia.webterm.viewmodels.SessionListViewModel
import wang.yaojia.webterm.viewmodels.SessionRow import wang.yaojia.webterm.viewmodels.SessionRow
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
import wang.yaojia.webterm.wiring.AppEnvironment
import java.util.UUID import java.util.UUID
/** /**
@@ -90,6 +100,14 @@ import java.util.UUID
* [LayoutMode.STACK] default means a caller that does not pass it gets today's phone behaviour. * [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. * @param onNewSessionInCwd pointer-menu「在当前目录开新会话」→ `attach(null, cwd)` in the row's cwd.
* `null` (the default) drops that entry. * `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 @Composable
public fun SessionListScreen( public fun SessionListScreen(
@@ -103,14 +121,26 @@ public fun SessionListScreen(
thumbnails: SessionThumbnails? = null, thumbnails: SessionThumbnails? = null,
layoutMode: LayoutMode = LayoutMode.STACK, layoutMode: LayoutMode = LayoutMode.STACK,
onNewSessionInCwd: ((String) -> Unit)? = null, onNewSessionInCwd: ((String) -> Unit)? = null,
watermarkStore: UnreadWatermarkStore? = rememberAppEnvironment()?.unreadWatermarkStore,
hostRemoval: HostRemovalGateway? = rememberAppEnvironment()?.hostRemoval,
onForeground: () -> Unit = rememberCertificateRotationTrigger(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
var confirmingRemove by remember { mutableStateOf(false) }
// STARTED-scoped poll: fetch→wait→repeat while foregrounded, cancelled on background (plan §6.6). // 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) { 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() viewModel.poll()
} }
} }
@@ -125,6 +155,7 @@ public fun SessionListScreen(
onPairHost = onPairHost, onPairHost = onPairHost,
onEnroll = onEnroll, onEnroll = onEnroll,
onImportCert = onImportCert, onImportCert = onImportCert,
onRemoveHost = if (hostRemoval != null) ({ confirmingRemove = true }) else null,
) )
}, },
) { padding -> ) { padding ->
@@ -142,8 +173,43 @@ public fun SessionListScreen(
thumbnails = thumbnails, thumbnails = thumbnails,
layoutMode = layoutMode, layoutMode = layoutMode,
onNewSessionInCwd = onNewSessionInCwd, 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 ────────────────────────────────────────────────────────────────────────────── // ── Top bar + host menu ──────────────────────────────────────────────────────────────────────────────
@@ -156,6 +222,7 @@ private fun SessionListTopBar(
onPairHost: () -> Unit, onPairHost: () -> Unit,
onEnroll: () -> Unit, onEnroll: () -> Unit,
onImportCert: () -> Unit, onImportCert: () -> Unit,
onRemoveHost: (() -> Unit)?,
) { ) {
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话" val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话"
@@ -175,13 +242,20 @@ private fun SessionListTopBar(
onPairHost = { menuOpen = false; onPairHost() }, onPairHost = { menuOpen = false; onPairHost() },
onEnroll = { menuOpen = false; onEnroll() }, onEnroll = { menuOpen = false; onEnroll() },
onImportCert = { menuOpen = false; onImportCert() }, 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 @Composable
private fun HostMenu( private fun HostMenu(
expanded: Boolean, expanded: Boolean,
@@ -191,6 +265,7 @@ private fun HostMenu(
onPairHost: () -> Unit, onPairHost: () -> Unit,
onEnroll: () -> Unit, onEnroll: () -> Unit,
onImportCert: () -> Unit, onImportCert: () -> Unit,
onRemoveHost: (() -> Unit)?,
) { ) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
for (host in hosts) { for (host in hosts) {
@@ -206,6 +281,13 @@ private fun HostMenu(
// 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS. // 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS.
DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll) DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll)
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert) DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
if (onRemoveHost != null && hosts.any { it.isActive }) {
HorizontalDivider()
DropdownMenuItem(
text = { Text(text = "移除此主机", color = MaterialTheme.colorScheme.error) },
onClick = onRemoveHost,
)
}
} }
} }
@@ -223,18 +305,27 @@ private fun SessionListBody(
thumbnails: SessionThumbnails?, thumbnails: SessionThumbnails?,
layoutMode: LayoutMode, layoutMode: LayoutMode,
onNewSessionInCwd: ((String) -> Unit)?, onNewSessionInCwd: ((String) -> Unit)?,
onDismissRemoveError: () -> Unit,
) { ) {
androidx.compose.material3.pulltorefresh.PullToRefreshBox( androidx.compose.material3.pulltorefresh.PullToRefreshBox(
isRefreshing = state.isRefreshing, isRefreshing = state.isRefreshing,
onRefresh = onRefresh, onRefresh = onRefresh,
modifier = Modifier.fillMaxSize().padding(contentPadding), modifier = Modifier.fillMaxSize().padding(contentPadding),
) { ) {
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 { when {
state.activeHostId == null -> state.activeHostId == null ->
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost) EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
state.rows.isEmpty() -> state.rows.isEmpty() ->
EmptyState( EmptyState(
message = if (state.hasLoadError) "无法加载会话列表。下拉重试。" else "该主机没有运行中的会话。", message = if (state.hasLoadError) {
"无法加载会话列表。下拉重试。"
} else {
"该主机没有运行中的会话。"
},
actionLabel = "开新会话", actionLabel = "开新会话",
onAction = onNewSession, onAction = onNewSession,
) )
@@ -249,6 +340,70 @@ private fun SessionListBody(
} }
} }
} }
}
}
/** "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 @Composable
private fun SessionList( private fun SessionList(

View File

@@ -46,8 +46,11 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.components.AwayDigestView import wang.yaojia.webterm.components.AwayDigestView
import wang.yaojia.webterm.components.BannerModel import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.components.DataStoreQuickReplyStore import wang.yaojia.webterm.components.DataStoreQuickReplyStore
@@ -55,6 +58,8 @@ import wang.yaojia.webterm.components.GateBanner
import wang.yaojia.webterm.components.HardwareKeyRouter import wang.yaojia.webterm.components.HardwareKeyRouter
import wang.yaojia.webterm.components.KeyBar import wang.yaojia.webterm.components.KeyBar
import wang.yaojia.webterm.components.PlanGateSheet 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.QuickReply
import wang.yaojia.webterm.components.QuickReplyPalette import wang.yaojia.webterm.components.QuickReplyPalette
import wang.yaojia.webterm.components.QuickReplyPaletteEditor import wang.yaojia.webterm.components.QuickReplyPaletteEditor
@@ -65,10 +70,16 @@ import wang.yaojia.webterm.designsystem.LocalReduceMotion
import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermColors import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.terminalview.RemoteTerminalView 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.GateKind
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wiring.RetainedSessionHolder import wang.yaojia.webterm.wiring.RetainedSessionHolder
import wang.yaojia.webterm.wiring.TerminalSessionControllerImpl 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. * The arguments to open a NEW session — the pure output of the "new session in current cwd" decision.
@@ -142,6 +153,10 @@ public object NewSessionInCwd {
* ([rememberAppQuickReplyStore] — the SAME `DataStore<Preferences>` singleton every other store uses, so * ([rememberAppQuickReplyStore] — the SAME `DataStore<Preferences>` singleton every other store uses, so
* the palette really persists); a test/preview passes an * the palette really persists); a test/preview passes an
* [InMemoryQuickReplyStore][wang.yaojia.webterm.components.InMemoryQuickReplyStore]. * [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 @Composable
public fun TerminalScreen( public fun TerminalScreen(
@@ -154,6 +169,8 @@ public fun TerminalScreen(
onWarmUp: suspend () -> Unit = {}, onWarmUp: suspend () -> Unit = {},
onDigestExpand: () -> Unit = {}, onDigestExpand: () -> Unit = {},
quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(), quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(),
queueGateway: QueueGateway? = rememberAppQueueGateway(endpoint),
uiConfigGate: UiConfigGate? = rememberAppEnvironment()?.uiConfigGate,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val activity = remember(context) { context.findActivity() } val activity = remember(context) { context.findActivity() }
@@ -243,6 +260,36 @@ public fun TerminalScreen(
} }
} }
// `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. // A25 quick-reply palette: one remembered presenter over the injected store, loaded once per store.
val palette = remember(quickReplyStore) { QuickReplyPalette(quickReplyStore) } val palette = remember(quickReplyStore) { QuickReplyPalette(quickReplyStore) }
val quickReplyChips by palette.chips.collectAsStateWithLifecycle() val quickReplyChips by palette.chips.collectAsStateWithLifecycle()
@@ -260,20 +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<RemoteTerminalView?>(null) }
var isSelecting by remember { mutableStateOf(false) }
var copyNotice by remember { mutableStateOf<String?>(null) }
LaunchedEffect(copyNotice) {
if (copyNotice != null) {
delay(COPY_NOTICE_MS)
copyNotice = null
}
}
Column(modifier = modifier.fillMaxSize()) { Column(modifier = modifier.fillMaxSize()) {
TerminalToolbar( TerminalToolbar(
title = title, title = title,
onNewSessionInCwd = requestNewSession, onNewSessionInCwd = requestNewSession,
onEditQuickReplies = { showPaletteEditor = true }, 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) ReconnectBanner(model = banner, onNewSession = requestNewSession)
// Cockpit surfaces (A22): away-digest (once per reconnect) + statusLine telemetry chips. Both hide // 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). // 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.digest?.let { AwayDigestView(digest = it, onExpand = onDigestExpand) } // onExpand → A28 timeline (wired)
gateUi.telemetry?.let { TelemetryChips(telemetry = it, nowMs = nowMs) } gateUi.telemetry?.let { TelemetryChips(telemetry = it, nowMs = nowMs) }
// Tool gate → an inline approve/reject card; plan gates use the ModalBottomSheet below. // 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 -> 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()) { Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
key(generation) { key(generation) {
@@ -297,6 +386,12 @@ public fun TerminalScreen(
controller.notifyForegrounded(null, null) 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) controller.remoteSession.attachView(remoteView)
// The host view IS the View to compose: :terminal-view now exposes its own // The host view IS the View to compose: :terminal-view now exposes its own
// `RemoteTerminalHostView` (a FrameLayout that owns focus + the IME contract and // `RemoteTerminalHostView` (a FrameLayout that owns focus + the IME contract and
@@ -306,7 +401,11 @@ public fun TerminalScreen(
}, },
// Config change / real background: unbind the view but the holder-owned emulator // 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). // 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() if (covered) PrivacyCover()
@@ -336,12 +435,70 @@ public fun TerminalScreen(
) )
} }
// Plan gate → a three-way ModalBottomSheet overlay. Dismissing (scrim/back) resolves NOTHING — the // Plan gate → a three-way ModalBottomSheet overlay, but ONLY on a host that permits auto-accept-edits
// gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in // (`GET /config/ui`); otherwise the two-way card above stands in. Dismissing (scrim/back) resolves
// GateViewModel.decide. // NOTHING — the gate stays held until an explicit decision (PlanGateSheet contract); the epoch
gateUi.gate?.takeIf { it.kind == GateKind.PLAN }?.let { planGate -> // stale-guard lives in GateViewModel.decide.
PlanGateSheet(gate = planGate, onDecide = gateViewModel::decide, onDismiss = {}) 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. */ /** Telemetry-chip staleness tick interval (ms) — re-reads the wall clock so chips grey as a frame ages. */
@@ -358,6 +515,9 @@ private fun TerminalToolbar(
title: String, title: String,
onNewSessionInCwd: () -> Unit, onNewSessionInCwd: () -> Unit,
onEditQuickReplies: () -> Unit, onEditQuickReplies: () -> Unit,
queueDepth: Int? = null,
onOpenQueue: (() -> Unit)? = null,
onCopySelection: (() -> Unit)? = null,
) { ) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Row( Row(
@@ -373,6 +533,14 @@ private fun TerminalToolbar(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), 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 = onEditQuickReplies) { Text("快捷回复") }
TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") } TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") }
} }

View File

@@ -10,11 +10,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import wang.yaojia.webterm.session.Adopted
import wang.yaojia.webterm.session.AwayDigest import wang.yaojia.webterm.session.AwayDigest
import wang.yaojia.webterm.session.Digest import wang.yaojia.webterm.session.Digest
import wang.yaojia.webterm.session.Exited import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.Gate import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.Queued
import wang.yaojia.webterm.session.SessionEvent import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.session.Telemetry import wang.yaojia.webterm.session.Telemetry
import wang.yaojia.webterm.wire.ApprovalPreview import wang.yaojia.webterm.wire.ApprovalPreview
@@ -59,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 * 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, * 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. * 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( public class GateViewModel(
private val controller: TerminalSessionController, private val controller: TerminalSessionController,
@@ -106,12 +121,25 @@ public class GateViewModel(
is Gate -> onGate(event.gate) is Gate -> onGate(event.gate)
is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry) is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry)
is Digest -> onDigest(event.digest) is Digest -> onDigest(event.digest)
// The session ended: a held gate is no longer decidable — clear it so no stale card lingers. // W2: the server's authoritative pending-prompt depth (never a local count).
is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null) is Queued -> _uiState.value = _uiState.value.copy(queueDepth = event.length.coerceAtLeast(0))
else -> Unit // Output / Connection / Adopted are not cockpit concerns. // 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?) { private fun onGate(gate: GateState?) {
// The preview is bound to the gate it describes: it arrives with it and is cleared with it, so // 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. // a resolved gate's command/diff can never sit under the NEXT gate's buttons.
@@ -136,8 +164,12 @@ public class GateViewModel(
* an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message. * an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message.
*/ */
public fun decide(decision: GateDecision, epoch: Int) { public fun decide(decision: GateDecision, epoch: Int) {
val held = _uiState.value.gate ?: return // canDecide line 1: no gate held → 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. 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. val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop.
controller.decideGate(epoch, message) controller.decideGate(epoch, message)
} }
@@ -182,6 +214,22 @@ public data class GateUiState(
val telemetry: StatusTelemetry? = null, val telemetry: StatusTelemetry? = null,
/** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */ /** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */
val digest: AwayDigest? = null, 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,
) )
/** /**

View File

@@ -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<QueueUiState> = _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<String> = 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) }
}

View File

@@ -14,6 +14,7 @@ import wang.yaojia.webterm.api.routes.ApiClientError
import wang.yaojia.webterm.designsystem.DisplayStatus import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
import wang.yaojia.webterm.session.TitleSanitizer import wang.yaojia.webterm.session.TitleSanitizer
import wang.yaojia.webterm.session.UnreadLedger import wang.yaojia.webterm.session.UnreadLedger
import wang.yaojia.webterm.wire.Tunables 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] * 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 * [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] * 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) and [killSession] * (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open), [killSession]
* (optimistic swipe-to-kill). Ports the iOS session-list surface. * (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) * ### Untrusted server boundary (plan §4 / §8)
* The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` → * The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` →
@@ -45,14 +53,16 @@ import kotlin.time.Duration
* ### Testability * ### Testability
* A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time with * 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], * no `Dispatchers.Main`/Robolectric. Its collaborators are seams ([SessionListGatewayFactory],
* [UnreadWatermarkStore]) so the fetch→row mapping, unread-dot logic and optimistic-kill path are all * [UnreadWatermarkStore], [HostRemovalGateway]) so the fetch→row mapping, unread-dot logic,
* JVM-tested; swipe/thumbnail/pull gestures are device-QA (plan §7). * 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( public class SessionListViewModel(
private val hostStore: HostStore, private val hostStore: HostStore,
private val gatewayFactory: SessionListGatewayFactory, private val gatewayFactory: SessionListGatewayFactory,
private val watermarkStore: UnreadWatermarkStore = InMemoryUnreadWatermarkStore(), watermarkStore: UnreadWatermarkStore? = null,
private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL, private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL,
removalGateway: HostRemovalGateway? = null,
) { ) {
private val _uiState = MutableStateFlow(SessionListUiState()) 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. */ /** The active host id; sticky across polls, defaults to the first host until the user switches. */
private var activeHostId: String? = null 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 ledger: UnreadLedger = UnreadLedger()
private var ledgerLoaded = false 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. * 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)`. * 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 row = _uiState.value.rows.firstOrNull { it.id == sessionId } ?: return
val at = row.info.lastOutputAt ?: return val at = row.info.lastOutputAt ?: return
ledger = ledger.record(sessionId.toString(), at) 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) }) } _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 ──────────────────────────────────────────────────────────────────────────────── // ── Internals ────────────────────────────────────────────────────────────────────────────────
private suspend fun fetch(markRefreshing: Boolean) { private suspend fun fetch(markRefreshing: Boolean) {
@@ -202,7 +301,7 @@ public class SessionListViewModel(
private suspend fun ensureLedgerLoaded() { private suspend fun ensureLedgerLoaded() {
if (ledgerLoaded) return if (ledgerLoaded) return
ledger = runCatching { watermarkStore.load() }.getOrDefault(UnreadLedger()) ledger = runCatching { watermarks.load() }.getOrDefault(UnreadLedger())
ledgerLoaded = true ledgerLoaded = true
} }
} }
@@ -234,6 +333,11 @@ public data class SessionListUiState(
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
/** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */ /** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */
val hasLoadError: Boolean = false, 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) 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 { public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()) : UnreadWatermarkStore {
@Volatile private var current: UnreadLedger = initial @Volatile private var current: UnreadLedger = initial
override suspend fun load(): UnreadLedger = current override suspend fun load(): UnreadLedger = current
@@ -307,3 +411,46 @@ public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()
current = ledger 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<String>)
}

View File

@@ -1,20 +1,37 @@
package wang.yaojia.webterm.wiring package wang.yaojia.webterm.wiring
import android.util.Log
import dagger.Lazy import dagger.Lazy
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.withContext
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.pairing.PairingProbeResult import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.api.pairing.runPairingProbe import wang.yaojia.webterm.api.pairing.runPairingProbe
import wang.yaojia.webterm.hostregistry.AuthCookieRecord import wang.yaojia.webterm.hostregistry.AuthCookieRecord
import wang.yaojia.webterm.hostregistry.AuthCookieStore import wang.yaojia.webterm.hostregistry.AuthCookieStore
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.LastSessionStore import wang.yaojia.webterm.hostregistry.LastSessionStore
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.transport.AuthCookieJar import wang.yaojia.webterm.transport.AuthCookieJar
import wang.yaojia.webterm.transport.AuthCookieSnapshot 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.PairingProber
import wang.yaojia.webterm.viewmodels.PersistedUnreadWatermarkStore
import wang.yaojia.webterm.viewmodels.QueueGateway
import wang.yaojia.webterm.viewmodels.TokenSubmission import wang.yaojia.webterm.viewmodels.TokenSubmission
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest import wang.yaojia.webterm.wire.HttpRequest
@@ -53,6 +70,10 @@ import javax.inject.Singleton
* transports (lazy). * transports (lazy).
* - [coldStartPolicy] — the cold-launch route seam (A29). * - [coldStartPolicy] — the cold-launch route seam (A29).
* - the `webterm_auth` cookie jar + its durable store — the optional `WEBTERM_TOKEN` gate. * - 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 * Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its
* [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder]. * [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder].
*/ */
@@ -81,6 +102,20 @@ public class AppEnvironment @Inject constructor(
* under JVM unit tests). * under JVM unit tests).
*/ */
private val thumbnailRasterizerLazy: Lazy<ThumbnailRasterizer>, private val thumbnailRasterizerLazy: Lazy<ThumbnailRasterizer>,
/**
* 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<CertificateRotationScheduler>,
/** 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]). */ /** One-shot cold-start restore of the persisted session cookie into the live jar (see [warmUp]). */
private val authCookieHydrator = AuthCookieHydrator( private val authCookieHydrator = AuthCookieHydrator(
@@ -142,6 +177,94 @@ public class AppEnvironment @Inject constructor(
rasterizer = thumbnailRasterizerLazy.get(), 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<RotationOutcome?> = rotationSchedulerLazy.get().lastOutcome
/** /**
* Build the shared `OkHttpClient`, rehydrate the persisted session cookie and first-touch the device * 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 * identity OFF `Main` (FIX 3). Resolving either transport builds the one shared client (which reads
@@ -159,6 +282,184 @@ public class AppEnvironment @Inject constructor(
apiClientFactory.warm() // HttpTransport reuses the SAME already-built client apiClientFactory.warm() // HttpTransport reuses the SAME already-built client
runCatching { identityRepositoryLazy.get().hasInstalledIdentity() } // first AndroidKeyStore/Tink touch 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<String, Boolean> = 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<String>): 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"
} }
} }

View File

@@ -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<RotationOutcome?>(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<RotationOutcome?> = 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<CompletableDeferred<RotationOutcome>?>(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<RotationOutcome>()
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,
)
}
}
}

View File

@@ -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<SessionEvent>(Channel.UNLIMITED)
val decisions = mutableListOf<Pair<Int, ClientMessage>>()
override fun controlEvents(): Flow<SessionEvent> = channel.receiveAsFlow()
override val output: Flow<ByteArray> = emptyFlow()
override fun start() = Unit
override fun sendInput(data: String) = Unit
override fun resize(cols: Int, rows: Int) = Unit
override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit
override fun decideGate(epoch: Int, message: ClientMessage) {
decisions += epoch to message
}
override fun close() = Unit
fun emit(event: SessionEvent) {
channel.trySend(event)
}
}
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
}
}

View File

@@ -141,6 +141,9 @@ class GateViewModelTest {
val fake = FakeController() val fake = FakeController()
val vm = GateViewModel(fake) val vm = GateViewModel(fake)
vm.bind(backgroundScope) 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)) fake.emit(planGate(3))
vm.decide(GateDecision.APPROVE, epoch = 3) // default review vm.decide(GateDecision.APPROVE, epoch = 3) // default review

View File

@@ -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<Any> = listOf(QueueSnapshot()),
private val writes: List<QueueWriteOutcome> = listOf(QueueWriteOutcome.Ok(1)),
) : QueueGateway {
val enqueued = mutableListOf<Triple<UUID, String, Boolean>>()
val cleared = mutableListOf<UUID>()
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)
}
}

View File

@@ -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<Host>) : HostStore {
var hosts: List<Host> = initial
override suspend fun loadAll(): List<Host> = hosts
override suspend fun upsert(host: Host): List<Host> = hosts
override suspend fun remove(id: String): List<Host> {
hosts = hosts.filterNot { it.id == id }
return hosts
}
}
private class FakeGateway(private val list: List<LiveSessionInfo>) : SessionListGateway {
override suspend fun liveSessions(): List<LiveSessionInfo> = 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<Pair<String, List<String>>>()
override suspend fun removeHost(hostId: String, sessionIds: List<String>) {
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())
}
}

View File

@@ -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("已复制"))
}
}

View File

@@ -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<String>()
private var clock: Instant = RENEW_AFTER
private var snapshot: DeviceRotationSnapshot? = snapshot()
private var readFault: Exception? = null
private val renewUrls = mutableListOf<String>()
private val recoverUrls = mutableListOf<String>()
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<Unit>? = 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<Unit>()
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<Unit>()
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")
}
}

View File

@@ -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<Throwable>()
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()
}
}

View File

@@ -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<Host>) : HostStore {
var hosts: List<Host> = initial
var removeError: Throwable? = null
override suspend fun loadAll(): List<Host> = hosts
override suspend fun upsert(host: Host): List<Host> = hosts
override suspend fun remove(id: String): List<Host> {
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<Pair<String, String>>()
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<String> = mutableListOf(),
): Triple<HostRemover, RecordingPush, Quad> {
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<String>()
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())
}
}

View File

@@ -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<String>()
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) }
}
}
}

View File

@@ -43,6 +43,17 @@ public class DeviceEnroller(
// AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no // 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. // process restart. Optional so the JVM orchestration tests can construct the enroller without it.
private val cacheRefresher: IdentityCacheRefresher? = null, 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() private val commitMutex = Mutex()
@@ -97,6 +108,40 @@ public class DeviceEnroller(
summaryOf(result) 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. */ /** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */
public suspend fun remove(): Unit = commitMutex.withLock { public suspend fun remove(): Unit = commitMutex.withLock {
certStore.clear() certStore.clear()
@@ -120,7 +165,13 @@ public class DeviceEnroller(
deviceId = result.deviceId, deviceId = result.deviceId,
deviceName = deviceName, deviceName = deviceName,
keyStoreAlias = alias, 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, 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( certStore.save(

View File

@@ -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,
)
}
}

View File

@@ -8,12 +8,23 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.DataInputStream import java.io.DataInputStream
import java.io.DataOutputStream import java.io.DataOutputStream
import java.io.EOFException
/** /**
* B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted * 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 * [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 * 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 * 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 * identity is what the handshake presents; this record only exists so renew can find the device and
@@ -24,6 +35,8 @@ public data class EnrollmentRecord(
val deviceName: String, val deviceName: String,
val keyStoreAlias: String, val keyStoreAlias: String,
val renewAfterEpochSeconds: Long, val renewAfterEpochSeconds: Long,
/** Control-plane base URL this device enrolled against; `""` when unknown (a legacy record). */
val controlPlaneUrl: String = "",
) { ) {
init { init {
require(deviceId.isNotBlank()) { "deviceId must not be blank" } require(deviceId.isNotBlank()) { "deviceId must not be blank" }
@@ -31,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 object EnrollmentRecordCodec {
public fun encode(record: EnrollmentRecord): ByteArray { public fun encode(record: EnrollmentRecord): ByteArray {
val out = ByteArrayOutputStream() val out = ByteArrayOutputStream()
@@ -40,6 +53,7 @@ public object EnrollmentRecordCodec {
data.writeUTF(record.deviceName) data.writeUTF(record.deviceName)
data.writeUTF(record.keyStoreAlias) data.writeUTF(record.keyStoreAlias)
data.writeLong(record.renewAfterEpochSeconds) data.writeLong(record.renewAfterEpochSeconds)
data.writeUTF(record.controlPlaneUrl)
} }
return out.toByteArray() return out.toByteArray()
} }
@@ -53,11 +67,25 @@ public object EnrollmentRecordCodec {
deviceName = data.readUTF(), deviceName = data.readUTF(),
keyStoreAlias = data.readUTF(), keyStoreAlias = data.readUTF(),
renewAfterEpochSeconds = data.readLong(), renewAfterEpochSeconds = data.readLong(),
controlPlaneUrl = readTrailingUtfOrBlank(data),
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e) 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) {
""
}
} }
/** /**

View File

@@ -5,15 +5,13 @@ import okhttp3.OkHttpClient
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull 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.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.testsupport.FakeHttpTransport import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HttpMethod 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 * 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 BASE = "https://cp.terminal.yaojia.wang"
const val ALIAS = "test-device-key" const val ALIAS = "test-device-key"
// Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory / // Real self-signed P-256 X.509 certs + a software key double, shared with the rotation tests.
// CertificateSummaryReader parse them exactly as they parse a server-issued leaf. const val LEAF_CN = RotationTestFixtures.LEAF_CN
const val LEAF_CN = "t1-device" const val CA_CN = RotationTestFixtures.CA_CN
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=="
fun loginBody(): ByteArray = fun loginBody(): ByteArray = RotationTestFixtures.loginBody()
"""{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()
fun enrollBody(deviceId: String = "dev-1"): ByteArray = fun enrollBody(deviceId: String = "dev-1"): ByteArray = RotationTestFixtures.enrollBody(deviceId)
"""
{"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 softwareKey(alias: String): HardwareBackedKey { fun softwareKey(alias: String): HardwareBackedKey = RotationTestFixtures.softwareKey(alias)
val kpg = KeyPairGenerator.getInstance("EC")
kpg.initialize(ECGenParameterSpec("secp256r1"))
val kp = kpg.generateKeyPair()
return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey)
}
} }
private val events = mutableListOf<String>() private val events = mutableListOf<String>()
private val transport = FakeHttpTransport() 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 certStore = RecordingCertStore(events)
private val recordStore = RecordingRecordStore(events) private val recordStore = RecordingRecordStore(events)
private val keyProvider = RecordingKeyProvider(events) private val keyProvider = RecordingKeyProvider(events)
private val refresher = RecordingRefresher(events) private val refresher = RecordingRefresher(events)
private fun enroller(): DeviceEnroller = private fun enroller(withRecovery: Boolean = true): DeviceEnroller =
DeviceEnroller( DeviceEnroller(
client = DeviceEnrollmentClient(BASE, transport), client = DeviceEnrollmentClient(BASE, transport),
certStore = certStore, certStore = certStore,
@@ -85,6 +62,7 @@ class DeviceEnrollerTest {
keyAlias = ALIAS, keyAlias = ALIAS,
keyProvider = keyProvider, keyProvider = keyProvider,
cacheRefresher = refresher, cacheRefresher = refresher,
recoveryClient = if (withRecovery) DeviceEnrollmentClient(BASE, recoveryTransport) else null,
) )
// ── enroll: commit sequencing (the security-critical invariant) ──────────────────────────── // ── 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") 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 ────────────────────────────────────────────────────────────────── // ── remove: full teardown ──────────────────────────────────────────────────────────────────
@Test @Test
@@ -240,17 +378,25 @@ class DeviceEnrollerTest {
private class RecordingCertStore(private val events: MutableList<String>) : CertStore { private class RecordingCertStore(private val events: MutableList<String>) : CertStore {
var saved: StoredIdentityMetadata? = null var saved: StoredIdentityMetadata? = null
var cleared = false 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) { override fun save(metadata: StoredIdentityMetadata) {
saved = metadata saved = metadata
current = metadata
events += "cert.save" events += "cert.save"
} }
override fun load(): StoredIdentityMetadata? = saved override fun load(): StoredIdentityMetadata? = current
override fun clear() { override fun clear() {
cleared = true cleared = true
saved = null saved = null
current = null
events += "cert.clear" events += "cert.clear"
} }
} }

View File

@@ -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
}
}
}

View File

@@ -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))
}
}
}

View File

@@ -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
}

View File

@@ -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<Preferences> {
val ctx = ApplicationProvider.getApplicationContext<Context>()
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"
}
}

View File

@@ -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.<hostId>"`, `"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<Preferences>,
) : SessionWatermarkStore {
override suspend fun loadAll(): Map<String, Long> =
SessionWatermarkCodec.decode(dataStore.data.first()[WATERMARKS_KEY]).forStorage()
override suspend fun replaceAll(watermarks: Map<String, Long>): Map<String, Long> =
writeTransform { watermarks.forStorage() }
override suspend fun remove(sessionId: String): Map<String, Long> =
writeTransform { it.removingWatermark(sessionId) }
private suspend fun writeTransform(
transform: (Map<String, Long>) -> Map<String, Long>,
): Map<String, Long> {
lateinit var updated: Map<String, Long>
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")
}
}

View File

@@ -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<String, Long> = 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<String, Long> = initial.forStorage()
override suspend fun loadAll(): Map<String, Long> = mutex.withLock { stored }
override suspend fun replaceAll(watermarks: Map<String, Long>): Map<String, Long> =
write { watermarks.forStorage() }
override suspend fun remove(sessionId: String): Map<String, Long> =
write { it.removingWatermark(sessionId) }
private suspend fun write(
transform: (Map<String, Long>) -> Map<String, Long>,
): Map<String, Long> = mutex.withLock {
val updated = transform(stored)
stored = updated
updated
}
}

View File

@@ -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, Long>): String = json.encodeToString(watermarks)
fun decode(raw: String?): Map<String, Long> {
if (raw.isNullOrBlank()) return emptyMap()
return try {
json.decodeFromString<Map<String, Long>>(raw)
} catch (_: Exception) {
emptyMap() // corrupted blob at rest → start clean, never crash
}
}
}

View File

@@ -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<String, Long>
/**
* 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<String, Long>): Map<String, Long>
/**
* 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<String, Long>
}
// ── 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<String, Long>.sanitizedWatermarks(): Map<String, Long> {
val out = LinkedHashMap<String, Long>(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<String, Long>.cappedWatermarks(): Map<String, Long> {
if (size <= MAX_WATERMARKS) return this
return entries
.sortedWith(compareByDescending<Map.Entry<String, Long>> { 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<String, Long>.removingWatermark(sessionId: String): Map<String, Long> {
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<String, Long>.forStorage(): Map<String, Long> =
sanitizedWatermarks().cappedWatermarks()

View File

@@ -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())
}
}

View File

@@ -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}"""))
}
}

View File

@@ -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<String, Long> =
(0 until count).associate { sessionId(it) to (it + 1).toLong() }
}

View File

@@ -1,7 +1,9 @@
package wang.yaojia.webterm.terminalview package wang.yaojia.webterm.terminalview
import android.content.Context import android.content.Context
import android.graphics.Canvas
import android.util.TypedValue import android.util.TypedValue
import android.view.ActionMode
import android.view.InputDevice import android.view.InputDevice
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
@@ -49,8 +51,11 @@ import kotlin.math.roundToInt
* 4. **text selection** — `startTextSelectionMode` arms an `ActionMode` whose Copy * 4. **text selection** — `startTextSelectionMode` arms an `ActionMode` whose Copy
* (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100) is * (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100) is
* `mTermSession.onCopyTextToClipboard(...)` and whose Paste (offsets 126-136) is * `mTermSession.onCopyTextToClipboard(...)` and whose Paste (offsets 126-136) is
* `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true, * `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true so
* which is a **recorded deviation from plan §6.5** (see that method for the full rationale). * 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-method audit (verified with `javap -p -c`; keep it current when the pinned version moves)
* | stock member | reachable here? | verdict | * | stock member | reachable here? | verdict |
@@ -68,6 +73,7 @@ import kotlin.math.roundToInt
* | `updateSize` | no | early-returns without a session; [TerminalResizeDriver] replaces it | * | `updateSize` | no | early-returns without a session; [TerminalResizeDriver] replaces it |
* | `attachSession` | no | never called — it is the process-forking path we replace | * | `attachSession` | no | never called — it is the process-forking path we replace |
* | `startTextSelectionMode` / `TextSelectionCursorController` | **no** | long press consumed (4) | * | `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 | * | `setTerminalCursorBlinker*` | never invoked | cosmetic gap vs stock: the cursor does not blink |
* *
* ### The latent renderer crash this also fixes * ### The latent renderer crash this also fixes
@@ -96,7 +102,10 @@ import kotlin.math.roundToInt
* delegates to ([TerminalKeyDecoder], [TerminalInputBytes], [TerminalResizeDriver], [TerminalScrollGesture]) * delegates to ([TerminalKeyDecoder], [TerminalInputBytes], [TerminalResizeDriver], [TerminalScrollGesture])
* is what the JVM tests pin. * is what the JVM tests pin.
*/ */
public class RemoteTerminalHostView internal constructor(context: Context) : FrameLayout(context) { 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. */ /** The stock Termux view. Rendering is entirely its own; we only feed it an emulator and input. */
internal val stockView: TerminalView = TerminalView(context, null) internal val stockView: TerminalView = TerminalView(context, null)
@@ -124,6 +133,11 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
val step = if (up) -1 else 1 val step = if (up) -1 else 1
stockView.topRow = min(NEWEST_ROW, max(-transcriptRows, stockView.topRow + step)) stockView.topRow = min(NEWEST_ROW, max(-transcriptRows, stockView.topRow + step))
stockView.invalidate() 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) { override fun sendKeys(data: String) {
@@ -134,6 +148,36 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
}, },
) )
// ── 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 { init {
addView(stockView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) addView(stockView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
// Builds TerminalRenderer → onDraw is safe and the cell metrics become readable. // Builds TerminalRenderer → onDraw is safe and the cell metrics become readable.
@@ -159,13 +203,140 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
internal fun bindEmulator(emulator: TerminalEmulator) { internal fun bindEmulator(emulator: TerminalEmulator) {
stockView.mEmulator = emulator stockView.mEmulator = emulator
// A new emulator means a new transcript: drop any half-finished drag rather than let it resolve // 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. // 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() scrollGesture.reset()
selection.clear()
} }
/** Invalidate/redraw the stock view for the current emulator state (main thread). */ /**
* 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() { 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() 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<TerminalSelectionRect> {
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())
} }
/** /**
@@ -287,12 +458,29 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
return super.dispatchGenericMotionEvent(event) return super.dispatchGenericMotionEvent(event)
} }
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { /**
* 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) 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) 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 { private fun routeTouch(event: MotionEvent): Boolean {
if (selection.isActive) return routeSelectionTouch(event)
eventInFlight = event eventInFlight = event
try { try {
return scrollGesture.onTouch(TouchFacts.of(event)) return scrollGesture.onTouch(TouchFacts.of(event))
@@ -301,6 +489,35 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
} }
} }
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 * 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 * wheel at the cell the gesture started on (one anchor per down-time) so a program tracking the mouse
@@ -349,6 +566,12 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra
/** No wheel gesture has been anchored yet (stock initialises `mMouseStartDownTime` to -1). */ /** No wheel gesture has been anchored yet (stock initialises `mMouseStartDownTime` to -1). */
const val NO_DOWN_TIME: Long = -1L 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. */ /** `getColumnAndRow` returns `[column, row]`, both 0-based; the wire protocol is 1-based. */
const val COLUMN_INDEX: Int = 0 const val COLUMN_INDEX: Int = 0
const val ROW_INDEX: Int = 1 const val ROW_INDEX: Int = 1

View File

@@ -17,9 +17,10 @@ import com.termux.terminal.TerminalEmulator
* *
* **Glyph rendering and the cursor stay 100% stock. Scrolling and text selection do not** — both stock * **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 * implementations dereference the absent `TerminalSession`, so [RemoteTerminalHostView] owns vertical
* drags and the long press is consumed ([WebTermTerminalViewClient.onLongPress], a recorded §6.5 * drags, and the long press is consumed ([WebTermTerminalViewClient.onLongPress]) and re-spent on a
* deviation). [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member: which are * first-party selection layer ([TerminalSelectionController]) rather than on the stock `ActionMode`, whose
* reachable, which are proven safe, and which had to be intercepted. * 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.
* *
* ### What this class owns * ### What this class owns
* It is the single place that knows which [RemoteTerminalSession] is bound, so it implements the * It is the single place that knows which [RemoteTerminalSession] is bound, so it implements the
@@ -33,9 +34,12 @@ import com.termux.terminal.TerminalEmulator
* - **resize** — [RemoteTerminalHostView.onSizeChanged] → [TerminalResizeDriver] → * - **resize** — [RemoteTerminalHostView.onSizeChanged] → [TerminalResizeDriver] →
* [RemoteTerminalSession.updateSize], which is the ONLY reason the server PTY ever learns the real * [RemoteTerminalSession.updateSize], which is the ONLY reason the server PTY ever learns the real
* grid: `TerminalView.updateSize()` early-returns without a session (offsets 1825). * grid: `TerminalView.updateSize()` early-returns without a session (offsets 1825).
* - **selection** — [WebTermTerminalViewClient.onLongPress] → [RemoteTerminalHostView.beginSelectionAt],
* then [copySelection] → `ClipboardManager`. Paste is untouched: it arrives through the IME.
* *
* DEVICE-QA (plan §7): rendering, IME/CJK composition, selection, link taps, focus behaviour and grid * DEVICE-QA (plan §7): rendering, IME/CJK composition, link taps, focus behaviour and grid parity against
* parity against web/iOS on the same physical size are verified on a device (risk R5). * 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) { public class RemoteTerminalView(context: Context) {
@@ -55,7 +59,11 @@ public class RemoteTerminalView(context: Context) {
} }
override fun onTapped() { override fun onTapped() {
terminalView.showSoftKeyboard() terminalView.onTapped()
}
override fun onLongPressAt(xPx: Float, yPx: Float) {
terminalView.beginSelectionAt(xPx, yPx)
} }
override fun onViewSize(widthPx: Int, heightPx: Int) { override fun onViewSize(widthPx: Int, heightPx: Int) {
@@ -107,11 +115,38 @@ public class RemoteTerminalView(context: Context) {
/** Layout-change outlet (A17/A21 install point) — the §6.4 device-switch reclaim hook. */ /** Layout-change outlet (A17/A21 install point) — the §6.4 device-switch reclaim hook. */
public var onViewSizeChanged: ((widthPx: Int, heightPx: Int) -> Unit)? = null public var onViewSizeChanged: ((widthPx: Int, heightPx: Int) -> Unit)? = null
/**
* 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 { init {
terminalView.sink = sink terminalView.sink = sink
// Installed BEFORE any emulator binds: touch, focus and the IME can all arrive first, and every // 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. // one of those stock paths dereferences `mClient` without a null check.
terminalView.installClient(WebTermTerminalViewClient(sink)) 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. */ /** Point the stock renderer at the forked emulator, then re-assert the measured grid on it. */

View File

@@ -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"
}
}

View File

@@ -26,9 +26,23 @@ internal interface TerminalInputSink {
/** Raw bytes onto the wire, verbatim (invariant #9 — never filtered). */ /** Raw bytes onto the wire, verbatim (invariant #9 — never filtered). */
fun sendInput(data: String) fun sendInput(data: String)
/** The user tapped the terminal: take focus and raise the soft keyboard. */ /**
* 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() 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). */ /** The host view was laid out at this pixel size (drives the §6.4 resize path). */
fun onViewSize(widthPx: Int, heightPx: Int) fun onViewSize(widthPx: Int, heightPx: Int)
} }

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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<TerminalSelectionRect> {
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,
)
}
}
}

View File

@@ -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<TerminalCell> {
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),
)
}

View File

@@ -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<TerminalSelectionRect>) {
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()
}
}

View File

@@ -36,7 +36,10 @@ import com.termux.view.TerminalViewClient
* reachable and which are proven safe. Rendering and the cursor stay stock; scrolling and text selection * 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]). * 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 * @param sink the single outlet — `:app`'s chord router, the emulator's live cursor modes, and the byte
* send pump. See [TerminalInputSink]. * send pump. See [TerminalInputSink].
*/ */
@@ -126,44 +129,31 @@ internal class WebTermTerminalViewClient(private val sink: TerminalInputSink) :
override fun onScale(scale: Float): Float = NO_SCALE override fun onScale(scale: Float): Float = NO_SCALE
/** /**
* MUST stay true — text selection is **cut**, a recorded deviation from plan §6.5. * Start a FIRST-PARTY text selection at the press, and consume the event.
* *
* `TerminalView$1.onLongPress` offsets 14-30 are `if (mClient.onLongPress(e)) return;`, so true is the * **The return value MUST stay true.** `TerminalView$1.onLongPress` offsets 14-30 are
* one supported way to stop `startTextSelectionMode` from arming. It has to be stopped, because the * `if (mClient.onLongPress(e)) return;`, so true is the one supported way to stop
* stock selection ActionMode is a crash rather than a feature without a `TerminalSession`: * `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 * `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 (**Copy**) are
* `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 (**Paste**) are * `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 (**Paste**) are
* `mTermSession.onPasteTextFromClipboard()` — both unguarded, both on the only two buttons the mode * `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. * 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).
* *
* The controller itself is otherwise session-free (it reads `TerminalBuffer.getSelectedText`), so a * Copy-out is therefore **no longer a feature gap**: the press is spent on the replacement layer
* clipboard path is buildable — but it needs its own ActionMode, its own copy action and its own * instead of being discarded. [TerminalSelection] carries the design — our own cell coordinates
* selection highlight, i.e. a feature, not a guard. Until then a long press is a no-op instead of a * and highlight, stock's session-free `TerminalBuffer.getSelectedText` for the extraction, and a direct
* loaded gun, and the deviation is reported to the plan owner rather than left silently dead. * `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 {
* Consume the long press, which suppresses stock text selection. event?.let { sink.onLongPressAt(it.x, it.y) }
* return true
* **RECORDED DEVIATION from plan §6.5** ("Selection/copy: Termux TextSelectionCursorController + }
* Android ActionMode → ClipboardManager"), and a KNOWN FEATURE GAP — not an oversight, and not the
* cheaper of two equal options. An adversarial review asked for stock selection to be RESTORED after
* an earlier revision made it unreachable. Disassembling the pinned artifact shows restoring it would
* only relocate the crash rather than remove it: the ActionMode's own handlers dereference the absent
* session. `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 are
* `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 are
* `mTermSession.onPasteTextFromClipboard()`. So `startTextSelectionMode` being reachable buys a
* selection UI whose **Copy button is an NPE** — strictly worse than no selection, because the user
* loses their work to a crash instead of finding the affordance absent.
*
* `mTermSession` cannot be supplied: `TerminalSession` is `final`, its constructor builds a
* process-bound `MainThreadHandler`, and this app deliberately has no local subprocess (plan §6.1).
*
* The real fix is a first-party selection layer that reads the emulator's `TerminalBuffer` and talks
* to `ClipboardManager` directly, bypassing `TextSelectionCursorController` entirely. That is a
* feature, not a crash fix, so it is tracked in DEVICE_QA_CHECKLIST rather than smuggled in here.
* Until then: copy-out is unavailable, and paste-in still works through the IME.
*/
override fun onLongPress(event: MotionEvent?): Boolean = true
override fun copyModeChanged(copyMode: Boolean) = Unit override fun copyModeChanged(copyMode: Boolean) = Unit

View File

@@ -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<String>()
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<String>()
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
}
}

View File

@@ -120,6 +120,7 @@ internal object StockTerminalViewFixture {
internal class RecordingInputSink : TerminalInputSink { internal class RecordingInputSink : TerminalInputSink {
val sent = mutableListOf<String>() val sent = mutableListOf<String>()
var taps = 0 var taps = 0
val longPresses = mutableListOf<Pair<Float, Float>>()
val sizes = mutableListOf<Pair<Int, Int>>() val sizes = mutableListOf<Pair<Int, Int>>()
override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false
@@ -132,6 +133,10 @@ internal class RecordingInputSink : TerminalInputSink {
taps++ taps++
} }
override fun onLongPressAt(xPx: Float, yPx: Float) {
longPresses += xPx to yPx
}
override fun onViewSize(widthPx: Int, heightPx: Int) { override fun onViewSize(widthPx: Int, heightPx: Int) {
sizes += widthPx to heightPx sizes += widthPx to heightPx
} }

View File

@@ -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
}
}

View File

@@ -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<String>()
override fun put(text: String): Boolean {
puts += text
return accepts
}
}
private class Recorder {
val changes = mutableListOf<Boolean>()
}
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
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -1,6 +1,9 @@
package wang.yaojia.webterm.terminalview package wang.yaojia.webterm.terminalview
import android.view.KeyEvent 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.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
@@ -26,6 +29,7 @@ class WebTermTerminalViewClientTest {
var taps = 0 var taps = 0
var sizes = mutableListOf<Pair<Int, Int>>() var sizes = mutableListOf<Pair<Int, Int>>()
var keysOfferedToApp = 0 var keysOfferedToApp = 0
val longPresses = mutableListOf<Pair<Float, Float>>()
fun setCursorApplicationMode(on: Boolean) { fun setCursorApplicationMode(on: Boolean) {
modes = TerminalCursorModes(cursorKeysApplication = on, keypadApplication = false) modes = TerminalCursorModes(cursorKeysApplication = on, keypadApplication = false)
@@ -45,6 +49,10 @@ class WebTermTerminalViewClientTest {
taps++ taps++
} }
override fun onLongPressAt(xPx: Float, yPx: Float) {
longPresses += xPx to yPx
}
override fun onViewSize(widthPx: Int, heightPx: Int) { override fun onViewSize(widthPx: Int, heightPx: Int) {
sizes += widthPx to heightPx sizes += widthPx to heightPx
} }
@@ -136,6 +144,20 @@ class WebTermTerminalViewClientTest {
) )
} }
@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<MotionEvent>(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 @Test
fun `a tap raises the keyboard and pinch-zoom is declined`() { fun `a tap raises the keyboard and pinch-zoom is declined`() {
val sink = FakeSink() val sink = FakeSink()
@@ -170,4 +192,9 @@ class WebTermTerminalViewClientTest {
assertFalse(client.readShiftKey()) assertFalse(client.readShiftKey())
assertFalse(client.readFnKey()) assertFalse(client.readFnKey())
} }
private companion object {
const val PRESS_X_PX: Float = 42f
const val PRESS_Y_PX: Float = 96f
}
} }