diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt index 00ea5eb..f5de8e2 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt @@ -15,8 +15,11 @@ import wang.yaojia.webterm.wire.HttpTransport * `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain, * deviceName, attestation? }` → 201 `{ deviceId, cert, caChain, * notBefore, notAfter, renewAfter }` - * `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The - * server schema is `.strict()`; NO keyAlg/subdomain/deviceName. + * `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 `{ cert, caChain, + * notAfter }`. The server schema is `.strict()`; NO + * keyAlg/subdomain/deviceName. + * `POST /device/:id/recover` [NO client cert — plain HTTPS] `{ cert, csr }` → 201 `{ cert, + * caChain, notAfter }`. The EXPIRED leaf's escape hatch. * * Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is * sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs @@ -29,8 +32,12 @@ public class DeviceEnrollmentClient( baseUrl: String, private val http: HttpTransport, ) { - /** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */ - private val base: String = baseUrl.trim().trimEnd('/') + /** + * Base control-plane URL with any trailing slash removed, so `baseUrl + path` is well-formed. + * Readable so a rotation driver can persist WHICH control plane a device enrolled with and renew + * against that same one (a URL is not a credential). + */ + public val baseUrl: String = baseUrl.trim().trimEnd('/') /** * One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is @@ -98,7 +105,38 @@ public class DeviceEnrollmentClient( ) val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew" val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken)) - return toResult(decodeOn201(response, EnrollResponseDto.serializer())) + // The renew 201 is `{ cert, caChain, notAfter }` — it does NOT echo deviceId, so the id we + // addressed the request to is the authoritative one. + return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId) + } + + /** + * Recover an EXPIRED leaf: `POST /device/:id/recover` carrying the lapsed [expiredCertDer] in the + * BODY plus a fresh [csrDer] over the SAME hardware key. + * + * **This call must NOT ride an mTLS transport.** `/renew` is authenticated by the very leaf it + * renews, so a lapsed leaf cannot renew itself — and no TLS terminator will even forward an expired + * client certificate (nginx answers a bare 400 under `ssl_verify_client optional`; `optional_no_ca` + * tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). The caller therefore supplies a + * PLAIN [HttpTransport] with no client identity; possession is proven by the CSR self-signature, + * which the control plane checks together with `CSR key == registered key`. + * + * No bearer is accepted on this path at all — the body is the whole credential story. Everything + * else the server enforces is unchanged: real X.509 path validation to the device-CA, SPIFFE parse, + * `notBefore` (never graced), `:id` must match the cert, and revocation still applies. Only + * `notAfter` is graced (30 days), so a leaf lapsed beyond that answers 401. + */ + public suspend fun recover(deviceId: String, expiredCertDer: ByteArray, csrDer: ByteArray): EnrollmentResult { + if (deviceId.isEmpty() || expiredCertDer.isEmpty() || csrDer.isEmpty()) { + throw DeviceEnrollmentError.InvalidRequest + } + val body = EnrollJson.encodeToString( + RecoverRequestBody.serializer(), + RecoverRequestBody(cert = base64(expiredCertDer), csr = base64(csrDer)), + ) + val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/recover" + val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearer = null)) + return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId) } // ── Request/response plumbing ──────────────────────────────────────────────────────────── @@ -114,7 +152,7 @@ public class DeviceEnrollmentClient( // Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty // string must never emit a bare "Bearer " header. if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer" - return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody) + return HttpRequest(method = method, url = baseUrl + path, headers = headers, body = jsonBody) } /** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error` @@ -127,6 +165,24 @@ public class DeviceEnrollmentClient( .getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse } + /** + * Map a re-issuance (renew / recover) 201, whose body carries no `deviceId`, using the [deviceId] + * the request was addressed to. `renewAfter` is normally absent here — the rotation policy + * re-derives it from the leaf's own validity window (`RotationPolicy.renewAfterFor`). + */ + private fun toReissueResult(dto: RenewResponseDto, deviceId: String): EnrollmentResult = + EnrollmentResult( + deviceId = deviceId, + certificate = decodeDerOrThrow(dto.cert), + caChain = dto.caChain.map { decodeDerOrThrow(it) }, + notBefore = parseInstantOrNull(dto.notBefore), + notAfter = parseInstantOrNull(dto.notAfter), + renewAfter = parseInstantOrNull(dto.renewAfter), + ) + + private fun decodeDerOrThrow(base64Der: String): ByteArray = + decodeBase64OrNull(base64Der) ?: throw DeviceEnrollmentError.MalformedResponse + private fun toResult(dto: EnrollResponseDto): EnrollmentResult { val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse val chain = dto.caChain.map { entry -> diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt index 27148a8..6f1afc3 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt @@ -97,6 +97,19 @@ internal data class EnrollRequestBody( @Serializable internal data class RenewRequestBody(val csr: String) +/** + * The `/device/:id/recover` request body — the EXPIRED leaf plus a fresh CSR over the same key. + * + * The lapsed certificate travels in the BODY (not as an mTLS client cert) because no TLS terminator + * forwards an expired client certificate: nginx answers a bare 400 under `ssl_verify_client optional`, + * and `optional_no_ca` tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`. Nothing is + * lost: the CSR is self-signed by the same private key and the control-plane signer enforces CSR + * proof-of-possession plus `CSR key == registered key`, so possession is proven exactly as the + * handshake used to prove it. The server schema is `.strict()` on these two keys. + */ +@Serializable +internal data class RecoverRequestBody(val cert: String, val csr: String) + @Serializable internal data class LoginResponseDto( val enrollToken: String, @@ -114,6 +127,25 @@ internal data class EnrollResponseDto( val renewAfter: String? = null, ) +/** + * The `/device/:id/renew` and `/device/:id/recover` 201 body — `{ cert, caChain, notAfter }` ONLY. + * + * Deliberately NOT [EnrollResponseDto]: the re-issuance routes do not echo `deviceId` (nor + * `notBefore`/`renewAfter`) — only `/device/enroll` does (`control-plane/src/api/renew.ts` vs + * `device-enroll.ts`). Decoding a renew 201 against the enroll DTO, whose `deviceId` is required, + * failed EVERY silent rotation with `MalformedResponse`. The device id is supplied by the caller (it + * is the path segment the request was addressed to, which is the authoritative value anyway), and the + * absent `renewAfter` is re-derived from the leaf by `RotationPolicy.renewAfterFor`. + */ +@Serializable +internal data class RenewResponseDto( + val cert: String, + val caChain: List = emptyList(), + val notBefore: String? = null, + val notAfter: String? = null, + val renewAfter: String? = null, +) + @Serializable internal data class ErrorDto(val error: String? = null) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicy.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicy.kt new file mode 100644 index 0000000..3168b13 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicy.kt @@ -0,0 +1,150 @@ +package wang.yaojia.webterm.api.enroll + +import java.time.Duration +import java.time.Instant + +/** + * Rotation timing of the installed device identity — the input to [RotationPolicy]. Android analogue + * of iOS `DeviceRenewalState`. + * + * [notAfter] is the leaf's HARD expiry (past it the TLS stack rejects the cert outright) and + * [renewAfter] is the advisory "renew from the same key now" instant. Only non-secret timing plus the + * non-secret [deviceId] (which is a URL path segment) surface here: the private key never leaves + * AndroidKeyStore and the cert bytes are never carried through this type, so it is safe to log. + */ +public data class DeviceRenewalState( + /** The enrolled device id — the `:id` in `POST /device/:id/renew` and `/device/:id/recover`. */ + val deviceId: String, + val notAfter: Instant?, + val renewAfter: Instant?, +) { + /** + * Is the leaf due for renewal as of [now]? A missing [renewAfter] NEVER triggers (fail-safe — the + * TLS stack is the real gate; the scheduler only pre-empts expiry). Mirrors + * [EnrollmentResult.isRenewalDue] and iOS `DeviceRenewalState.isRenewalDue`. + */ + public fun isRenewalDue(now: Instant): Boolean { + val due = renewAfter ?: return false + return !now.isBefore(due) // now >= renewAfter + } +} + +/** What a scheduler must do for one rotation pass — the total answer of [RotationPolicy.decide]. */ +public enum class RotationDecision { + /** Nothing to do: the renew window has not opened (the cheap, common case). */ + NOT_DUE, + + /** An attempt IS due but the previous one failed too recently — wait, do not hammer. */ + BACKING_OFF, + + /** Still valid: re-CSR from the same key and renew over mTLS (`POST /device/:id/renew`). */ + RENEW, + + /** + * EXPIRED but inside the recovery grace: the leaf can no longer authenticate its own re-issuance, + * so recover over PLAIN HTTPS with the lapsed cert in the body (`POST /device/:id/recover`). + */ + RECOVER, + + /** TERMINAL — expired past the grace window. No request can succeed; only a fresh enroll can. */ + RE_ENROLL_REQUIRED, +} + +/** + * The pure, JVM-testable rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`, + * `TerminalGridMath`). Given "now", the leaf's timing and the last failed attempt it answers + * renew / recover / wait / re-enroll — no clock, no keystore, no network, so every branch is a unit + * test rather than a device observation. + * + * Ported from iOS `CertificateRotationScheduler`'s timing rules, plus the two branches the phone + * track needs and iOS lacks (it can only renew, so an iOS device whose leaf lapses is stranded): + * - [RotationDecision.RECOVER] — the `/device/:id/recover` escape hatch, because no TLS terminator + * forwards an expired client certificate, so a lapsed leaf can never authenticate its own renewal. + * - [RotationDecision.RE_ENROLL_REQUIRED] — reported ONCE as terminal instead of retrying forever + * (the host-track agent logged the same failure 6380 times before this rule existed). + */ +public object RotationPolicy { + /** + * The control plane issues `renewAfter = notBefore + 2/3 · lifetime` + * (`control-plane/src/api/device-enroll.ts` `DEFAULT_RENEW_FRACTION`). The client mirrors the + * fraction as exact integer duration math so [renewAfterFor] lands on the same instant. + */ + private const val RENEW_FRACTION_NUMERATOR: Long = 2L + private const val RENEW_FRACTION_DENOMINATOR: Long = 3L + + /** + * How long after `notAfter` a lapsed leaf may still be swapped via `/device/:id/recover` + * (30 days) — the same window the control plane's `DEFAULT_EXPIRED_RENEW_GRACE_MS` allows. Asking + * outside it is pointless: the server answers 401. + */ + public val DEFAULT_EXPIRED_RECOVERY_GRACE: Duration = Duration.ofDays(30) + + /** + * Minimum gap after a FAILED attempt before another is made. A pass runs on every app + * foreground, and the control plane rate-limits renewals to 30/hour per identity, so an + * un-throttled retry converts one transient failure into a 429 storm. + */ + public val DEFAULT_FAILURE_BACKOFF: Duration = Duration.ofMinutes(15) + + /** + * Derive the advisory renew instant from the leaf's own validity window. + * + * This derivation is LOAD-BEARING, not a convenience: `POST /device/:id/renew` and + * `POST /device/:id/recover` answer `{cert, caChain, notAfter}` only — neither returns + * `renewAfter` (only `/device/enroll` does). A client that persisted the enroll-time value and + * never recomputed it would rotate exactly once and then report "not due" until the cert died. + * + * Returns null when either bound is unknown or the window is not a lifetime (notAfter <= + * notBefore) — fail-safe, because [decide] treats a null [DeviceRenewalState.renewAfter] as + * never-due rather than inventing a past instant that would renew-storm. + */ + public fun renewAfterFor(notBefore: Instant?, notAfter: Instant?): Instant? { + if (notBefore == null || notAfter == null) return null + if (!notBefore.isBefore(notAfter)) return null + val lifetime = Duration.between(notBefore, notAfter) + return notBefore.plus( + lifetime.multipliedBy(RENEW_FRACTION_NUMERATOR).dividedBy(RENEW_FRACTION_DENOMINATOR), + ) + } + + /** + * The whole decision table, in priority order: + * 1. expired past [expiredRecoveryGrace] → [RotationDecision.RE_ENROLL_REQUIRED] (terminal; it + * outranks the backoff because no amount of waiting can help), + * 2. otherwise pick the desired action — expired → RECOVER, [DeviceRenewalState.isRenewalDue] → + * RENEW, else NOT_DUE, + * 3. an idle leaf stays [RotationDecision.NOT_DUE] (there is no attempt to throttle), + * 4. a desired action within [failureBackoff] of [lastFailureAt] → [RotationDecision.BACKING_OFF]. + * + * @param lastFailureAt when the last attempt FAILED (null = none, or the last one succeeded). + */ + public fun decide( + state: DeviceRenewalState, + now: Instant, + lastFailureAt: Instant? = null, + expiredRecoveryGrace: Duration = DEFAULT_EXPIRED_RECOVERY_GRACE, + failureBackoff: Duration = DEFAULT_FAILURE_BACKOFF, + ): RotationDecision { + require(!expiredRecoveryGrace.isNegative) { "expiredRecoveryGrace must not be negative" } + require(!failureBackoff.isNegative) { "failureBackoff must not be negative" } + + val notAfter = state.notAfter + // Terminal first: past `notAfter + grace` nothing can succeed. A non-negative grace makes this + // strictly stronger than "expired", so it needs no separate expiry guard. + if (notAfter != null && now.isAfter(notAfter.plus(expiredRecoveryGrace))) { + return RotationDecision.RE_ENROLL_REQUIRED + } + val isExpired = notAfter != null && now.isAfter(notAfter) + + val desired = when { + isExpired -> RotationDecision.RECOVER + state.isRenewalDue(now) -> RotationDecision.RENEW + else -> RotationDecision.NOT_DUE + } + if (desired == RotationDecision.NOT_DUE) return RotationDecision.NOT_DUE + if (lastFailureAt != null && now.isBefore(lastFailureAt.plus(failureBackoff))) { + return RotationDecision.BACKING_OFF + } + return desired + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt index f25e0e6..ff06bb4 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt @@ -42,4 +42,9 @@ public data class LiveSessionInfo( /** Server ms timestamp of the last PTY output (== createdAt until first output). Additive * optional field — pre-P1 servers omit it; `null` means "no unread data source". */ val lastOutputAt: Long? = null, + /** W2 pending prompt-queue depth (`src/types.ts` `queueLength?`) — what the "N queued" badge + * renders. Additive OPTIONAL: `null` on a pre-W2 server (queue unsupported / unknown), `0` when + * the queue is empty; the badge shows only for a positive value. Decoded tolerantly + * ([LossyIntSerializer]) so a garbled value can never hide a running session. */ + @Serializable(with = LossyIntSerializer::class) val queueLength: Int? = null, ) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PromptQueue.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PromptQueue.kt new file mode 100644 index 0000000..f689994 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PromptQueue.kt @@ -0,0 +1,77 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.Serializable + +/** + * W2 prompt queue — the server-side FIFO of follow-up prompts injected into a session's PTY the next + * time Claude goes idle (`src/server.ts` ~605/644/654, `SessionManager.enqueueFollowup`/`clearQueue`). + * + * A queued entry is **raw keyboard input for a shell** (invariant #9 / byte-shuttle): it is stored and + * later written to the PTY verbatim. Nothing on this path may trim, escape, re-encode or normalise it — + * the only transformation is the server appending `\r` when the caller passed `appendEnter = true`. + */ + +/** + * `GET /live-sessions/:id/queue` 200 → `{ length, items }` — the pending depth plus the queued texts + * (verbatim keystroke bytes). Both fields default, so a missing/garbled key degrades to an empty queue + * instead of throwing; a non-object body yields `null` from `LossyDecode` (→ `InvalidResponseBody`). + */ +@Serializable +public data class QueueSnapshot( + /** Pending entries on the server. Equals `items.size` for a healthy server; trust [items] for + * rendering and [length] only as the badge number the server itself broadcasts. */ + val length: Int = 0, + /** The queued prompts in FIFO order, verbatim. */ + val items: List = emptyList(), +) + +/** + * Outcome of the two GUARDED queue writes — `POST` (enqueue) and `DELETE` (cancel-all). One union for + * both ops (the `GitWriteOutcome` precedent: one union, six routes); [Full], [TooLarge] and [Disabled] + * are simply unreachable for the DELETE, whose handler neither validates text nor consults the + * `QUEUE_ENABLED` kill-switch. + * + * Every variant maps to different UI behaviour, which is why they are typed rather than folded into a + * status code: + * - [Ok] — the server accepted it; [length] is the authoritative new depth for the "N queued" badge. + * - [Full] — 409, the bounded FIFO is at `QUEUE_MAX_ITEMS` (default 10). Cancel something first; + * the server never silently drops. + * - [TooLarge] — 413, over `QUEUE_ITEM_MAX_BYTES` (default 4 KiB, server-configurable — the client + * deliberately does NOT pre-validate size, which would invent a policy it cannot know). + * - [Disabled] — 503, `QUEUE_ENABLED=0` on this host: hide the affordance, do not retry. + * - [SessionGone] — 404, unknown or already-exited session. + * - [RateLimited] — 429 from the shared `QUEUE_RATE_MAX = 20`/minute/IP bucket. **Never auto-retry**; + * a retry loop just burns the same budget a real enqueue needs. + * - [Rejected] — any other 4xx/5xx, carrying the server's SAFE `error` string verbatim (`message` may + * be `null` when the body is empty, e.g. the Origin guard's bare 403). + */ +public sealed interface QueueWriteOutcome { + /** 200 — accepted; [length] is the new pending depth (0 for a cleared queue). */ + public data class Ok(val length: Int) : QueueWriteOutcome + + /** 409 — the bounded FIFO is full (`QUEUE_MAX_ITEMS`). */ + public data object Full : QueueWriteOutcome + + /** 413 — the prompt exceeds the server's per-item byte cap. */ + public data object TooLarge : QueueWriteOutcome + + /** 503 — the queue feature is switched off on this host. */ + public data object Disabled : QueueWriteOutcome + + /** 404 — the session is unknown or has already exited. */ + public data object SessionGone : QueueWriteOutcome + + /** 429 — shared per-IP queue bucket. Do NOT auto-retry. */ + public data object RateLimited : QueueWriteOutcome + + /** Any other failure, with the server's inert message (null when unparseable/empty). */ + public data class Rejected(val status: Int, val message: String?) : QueueWriteOutcome +} + +/** + * Read the new depth out of a queue write's 200 body (`{ length }`). The status already says it + * succeeded, so a missing/garbled body degrades to `0` rather than throwing — the next `queue` read or + * `queue` WS frame re-establishes the true depth. + */ +internal fun decodeQueueDepth(bytes: ByteArray): Int = + LossyDecode.objectOrNull(bytes, QueueSnapshot.serializer())?.length ?: 0 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt index 2a6df4e..54fab0b 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt @@ -2,6 +2,8 @@ package wang.yaojia.webterm.api.models import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor @@ -9,7 +11,9 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.intOrNull import wang.yaojia.webterm.wire.ClaudeStatus import java.util.UUID @@ -36,6 +40,23 @@ internal object ClaudeStatusSerializer : KSerializer { override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire) } +/** + * A tolerant `Int?` field decoder: a wrong-typed/garbled value degrades to `null` instead of failing + * the whole enclosing object. Used for ADDITIVE OPTIONAL numeric fields (e.g. + * `LiveSessionInfo.queueLength`) where dropping the entry would hide a running session for the sake of + * a badge. Required numeric fields deliberately keep the strict built-in behaviour. + */ +internal object LossyIntSerializer : KSerializer { + private val delegate: KSerializer = Int.serializer().nullable + override val descriptor: SerialDescriptor = delegate.descriptor + override fun serialize(encoder: Encoder, value: Int?) = delegate.serialize(encoder, value) + override fun deserialize(decoder: Decoder): Int? { + val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder) + val primitive = json.decodeJsonElement() as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content.toIntOrNull() else primitive.intOrNull + } +} + /** * A `List` serializer that drops malformed elements and degrades a non-array to `[]` — the * Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`, diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt index 4e2b0a3..e4e6035 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt @@ -11,6 +11,9 @@ import wang.yaojia.webterm.api.models.PrStatus import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.PruneWorktreesResult +import wang.yaojia.webterm.api.models.QueueSnapshot +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.api.models.decodeQueueDepth import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult @@ -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 * `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */ 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 ────────────── /** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */ diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt index 3bd6d2f..2104c86 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt @@ -44,6 +44,11 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u /** 500 from `GET /projects/detail` — the server failed reading the repo. */ public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。") + /** An empty follow-up prompt was passed to `POST /live-sessions/:id/queue` (which the server 400s + * as `text must be a non-empty string`). Raised client-side, before any network I/O — mirrors the + * [ProjectPathInvalid] precedent. */ + public data object QueueTextEmpty : ApiClientError("要排队的内容为空——请先输入要发送的提示词。") + /** 500 from `GET /projects/log` — the server failed reading the git log. */ public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。") diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt index cbddb4c..9c795bb 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt @@ -12,8 +12,11 @@ internal object HttpStatus { const val BAD_REQUEST = 400 const val FORBIDDEN = 403 const val NOT_FOUND = 404 + const val CONFLICT = 409 + const val PAYLOAD_TOO_LARGE = 413 const val TOO_MANY_REQUESTS = 429 const val INTERNAL_SERVER_ERROR = 500 + const val SERVICE_UNAVAILABLE = 503 /** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */ const val CLIENT_ERROR_MIN = 400 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt index eb27f8f..4fdbfca 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt @@ -12,10 +12,10 @@ import java.util.UUID * `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token** * pair (this client uses FCM, not APNs/VAPID). * - * RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui` - * · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs` - * G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs` - * · `POST|DELETE /push/fcm-token` + * RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue` + * · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs` + * G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue` + * · `POST /hook/decision` · `PUT /prefs` · `POST|DELETE /push/fcm-token` * * KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD * of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` + @@ -70,6 +70,13 @@ internal object Endpoints { fun getPrefs(): ApiRoute = ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY) + /** + * `GET /live-sessions/:id/queue` — RO (W2). Same threat model as `GET /live-sessions`, so the + * server stamps no Origin guard on it and neither may we. + */ + fun queue(id: UUID): ApiRoute = + ApiRoute(HttpMethod.GET, queuePath(id), OriginPolicy.READ_ONLY) + /** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */ fun projectPr(path: String): ApiRoute = ApiRoute( @@ -175,6 +182,36 @@ internal object Endpoints { fun gitPush(path: String): ApiRoute = jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path)) + // ── G: W2 prompt queue (enqueue / cancel-all) ────────────────────────────────────────── + + /** + * `POST /live-sessions/:id/queue` — G. Body is exactly `{ text, appendEnter }` (express.json, + * 16 kb limit). + * + * [text] is RAW KEYBOARD INPUT for a shell and is carried VERBATIM (invariant #9): JSON string + * encoding is the only transformation, and it round-trips byte-exactly through `express.json`. + * Never trim/escape/normalise it here. [appendEnter] is a FLAG — the server materialises the + * trailing `\r` so the stored entry is byte-identical to a keystroke; the client must not append + * one itself. + */ + fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean): ApiRoute = + jsonBodyRoute( + HttpMethod.POST, + queuePath(id), + EnqueueBody.serializer(), + EnqueueBody(text, appendEnter), + ) + + /** + * `DELETE /live-sessions/:id/queue` — G, cancel-all escape hatch. Carries **no** body: unlike + * `DELETE /projects/worktree` (the body-carrying DELETE precedent), this handler reads only `:id` + * and clears the whole FIFO, so a body would be dead weight the server never parses. + */ + fun clearQueue(id: UUID): ApiRoute = + ApiRoute(HttpMethod.DELETE, queuePath(id), OriginPolicy.GUARDED) + + private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue" + /** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */ private fun jsonBodyRoute( method: HttpMethod, @@ -242,4 +279,7 @@ internal object Endpoints { @kotlinx.serialization.Serializable private data class FetchBody(val path: String) + + @Serializable + private data class EnqueueBody(val text: String, val appendEnter: Boolean) } diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt index 4deadf2..51b95fd 100644 --- a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt @@ -253,6 +253,137 @@ class DeviceEnrollmentClientTest { assertTrue(transport.recordedRequests.isEmpty()) } + @Test + fun renewDecodesTheRealServerResponseWhichCarriesNoDeviceId() = runTest { + // THE REAL WIRE SHAPE: `/device/:id/renew` answers `{cert, caChain, notAfter}` — it does NOT + // echo deviceId/notBefore/renewAfter (only `/device/enroll` does; see + // control-plane/src/api/renew.ts). Decoding it against the enroll DTO (deviceId required) + // made every silent renewal fail as MalformedResponse. The device id comes from the path we + // called, which is authoritative anyway. + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, + body = """{"cert":"MAEC","caChain":["MAED"],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(), + ) + + val result = client.renew("dev-9", byteArrayOf(0x02)) + + assertEquals("dev-9", result.deviceId, "the device id comes from the path segment we called") + assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter) + assertNull(result.notBefore, "the renew 201 carries no notBefore") + assertNull(result.renewAfter, "the renew 201 carries no renewAfter — the client derives it") + assertEquals(1, result.caChain.size) + } + + // ── recover (EXPIRED-leaf escape hatch — plain HTTPS, cert in the BODY, never mTLS) ────────── + + @Test + fun recoverPostsTheExpiredLeafAndAFreshCsrWithNoAuthorizationHeader() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201, + body = """{"cert":"MAEC","caChain":[],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(), + ) + val expiredLeaf = byteArrayOf(0x30, 0x11) + val csr = byteArrayOf(0x30, 0x22) + + val result = client.recover("dev-9", expiredLeaf, csr) + + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/device/dev-9/recover", request.url) + // Recovery is NOT mTLS-authenticated and carries no bearer: an expired client certificate + // cannot authenticate its own re-issuance (no TLS terminator will even forward one), so the + // lapsed cert travels in the BODY and proof-of-possession comes from the CSR self-signature. + assertNull(request.headers["Authorization"], "recovery carries no bearer") + val obj = bodyObject(request) + assertEquals(setOf("cert", "csr"), obj.keys, "the /recover schema is .strict() on {cert, csr}") + assertEquals(Base64.getEncoder().encodeToString(expiredLeaf), obj["cert"]!!.jsonPrimitive.content) + assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content) + assertEquals("dev-9", result.deviceId) + assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter) + } + + @Test + fun recoverPercentEncodesTheDeviceIdPathSegment() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/a%2Fb/recover", status = 201, + body = """{"cert":"MAEC","caChain":[]}""".toByteArray(), + ) + client.recover("a/b", byteArrayOf(1), byteArrayOf(2)) + assertEquals("$BASE/device/a%2Fb/recover", transport.recordedRequests.single().url) + } + + @Test + fun recoverRejectsEmptyArgumentsBeforeAnyNetworkIo() = runTest { + assertEquals( + DeviceEnrollmentError.InvalidRequest, + runCatching { client.recover("", byteArrayOf(1), byteArrayOf(1)) }.exceptionOrNull(), + ) + assertEquals( + DeviceEnrollmentError.InvalidRequest, + runCatching { client.recover("d", ByteArray(0), byteArrayOf(1)) }.exceptionOrNull(), + ) + assertEquals( + DeviceEnrollmentError.InvalidRequest, + runCatching { client.recover("d", byteArrayOf(1), ByteArray(0)) }.exceptionOrNull(), + ) + assertTrue(transport.recordedRequests.isEmpty(), "no I/O for a request that cannot succeed") + } + + @Test + fun recoverSurfacesA401BeyondTheGraceWindowWithoutLeakingTheCert() = runTest { + // Past `DEFAULT_EXPIRED_RENEW_GRACE_MS` the control plane answers a uniform 401. The raised + // error must carry the STATUS + the server's `error` code only — never the submitted cert or + // CSR bytes, which would put credential material into a crash log. + val expiredLeaf = byteArrayOf(0x30, 0x11, 0x22, 0x33) + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 401, + body = """{"error":"rejected"}""".toByteArray(), + ) + + val error = runCatching { client.recover("dev-9", expiredLeaf, byteArrayOf(0x30, 0x44)) }.exceptionOrNull() + + assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error) + val rendered = "${error!!.message} $error" + assertFalse( + rendered.contains(Base64.getEncoder().encodeToString(expiredLeaf)), + "the raised error must not carry the certificate bytes", + ) + assertFalse(rendered.contains("MDE"), "no base64 credential material in the error rendering") + } + + @Test + fun recoverSurfacesA403ForARevokedDevice() = runTest { + // Recovery NEVER bypasses revocation (renew.test.ts CP6e) — surface the 403 verbatim. + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 403, + body = """{"error":"rejected"}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.Http(403, "rejected"), + runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(), + ) + } + + @Test + fun recoverThrowsMalformedResponseOnAnUndecodable201() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201, + body = """{"cert":"@@not-base64@@"}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.MalformedResponse, + runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(), + ) + } + + @Test + fun theBaseUrlIsReadableSoTheRotationTargetCanBePersisted() = runTest { + // The rotation driver must renew against the SAME control plane the device enrolled with; the + // enroller persists this alongside the enrollment record. + assertEquals(BASE, client.baseUrl) + assertEquals(BASE, DeviceEnrollmentClient("$BASE/", transport).baseUrl, "a trailing slash is trimmed") + } + // ── isRenewalDue seam ────────────────────────────────────────────────────────────────────── @Test diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicyTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicyTest.kt new file mode 100644 index 0000000..7b7b315 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicyTest.kt @@ -0,0 +1,228 @@ +package wang.yaojia.webterm.api.enroll + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Duration +import java.time.Instant + +/** + * The PURE rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`, + * `TerminalGridMath` — a policy is a function, so it is exhaustively testable with no clock, no + * keystore and no network). Ports the iOS `CertificateRotationSchedulerTests` timing cases and adds + * the two the phone track needs and iOS lacks: the EXPIRED-leaf `/device/:id/recover` window and the + * terminal beyond-grace state. + */ +class RotationPolicyTest { + private companion object { + val NOT_BEFORE: Instant = Instant.parse("2026-08-06T00:00:00Z") + val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z") + // notBefore + 2/3 · 61d lifetime — the value the control plane itself computes. + val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z") + + fun state( + notAfter: Instant? = NOT_AFTER, + renewAfter: Instant? = RENEW_AFTER, + ): DeviceRenewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter) + } + + // ── renewAfter derivation (the client MUST derive it: renew/recover 201s carry no renewAfter) ── + + @Test + fun renewAfterIsTwoThirdsThroughTheLeafLifetime() { + // The control plane computes `notBefore + floor(2/3 · lifetime)` (device-enroll.ts + // DEFAULT_RENEW_FRACTION); the client must land on the same instant from the leaf alone, + // because `/device/:id/renew` and `/device/:id/recover` return `{cert, caChain, notAfter}` + // ONLY — no renewAfter. Without this derivation rotation would fire exactly once, ever. + assertEquals(RENEW_AFTER, RotationPolicy.renewAfterFor(NOT_BEFORE, NOT_AFTER)) + } + + @Test + fun renewAfterIsNullWhenEitherBoundIsUnknown() { + assertNull(RotationPolicy.renewAfterFor(null, NOT_AFTER)) + assertNull(RotationPolicy.renewAfterFor(NOT_BEFORE, null)) + } + + @Test + fun renewAfterIsNullForANonsensicalWindow() { + // notAfter before notBefore is not a lifetime — degrade to "unknown" (fail-safe: the TLS + // stack is the real gate) instead of inventing a past instant that would renew-storm. + assertNull(RotationPolicy.renewAfterFor(NOT_AFTER, NOT_BEFORE)) + } + + // ── the wait branch ──────────────────────────────────────────────────────────────────────── + + @Test + fun aLeafInsideItsRenewWindowIsNotDue() { + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide(state(), now = RENEW_AFTER.minusSeconds(1)), + ) + } + + @Test + fun aMissingRenewAfterNeverTriggers() { + // iOS fail-safe, ported verbatim: absent timing NEVER renews — the TLS stack is the real gate + // and the scheduler only pre-empts expiry. + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide(state(renewAfter = null), now = NOT_AFTER.minusSeconds(1)), + ) + assertFalse(state(renewAfter = null).isRenewalDue(NOT_AFTER.minusSeconds(1))) + } + + @Test + fun anUnknownNotAfterFallsBackToTheRenewAfterBranch() { + // A cert whose expiry could not be read must not be treated as expired (that would push a + // working device into recovery); it is judged solely on the advisory renew timing. + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER.minusSeconds(1)), + ) + assertEquals( + RotationDecision.RENEW, + RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER), + ) + } + + // ── the renew branch ─────────────────────────────────────────────────────────────────────── + + @Test + fun renewFiresAtTheRenewAfterBoundary() { + assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = RENEW_AFTER)) + assertTrue(state().isRenewalDue(RENEW_AFTER), "the >= boundary renews (iOS parity)") + } + + @Test + fun renewStillFiresAtTheLastInstantBeforeExpiry() { + assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = NOT_AFTER)) + } + + // ── the recover branch (expired leaf — the phone-track deadlock escape hatch) ─────────────── + + @Test + fun anExpiredLeafInsideTheGraceWindowRecovers() { + // mTLS `/renew` cannot authenticate an expired leaf (nginx refuses to forward one at all), so + // the only route left is the plain-HTTPS `/device/:id/recover`. + assertEquals( + RotationDecision.RECOVER, + RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(8))), + ) + } + + @Test + fun recoveryIsStillAvailableAtTheExactGraceBoundary() { + val grace = Duration.ofDays(30) + assertEquals( + RotationDecision.RECOVER, + RotationPolicy.decide(state(), now = NOT_AFTER.plus(grace), expiredRecoveryGrace = grace), + ) + } + + @Test + fun aLeafExpiredBeyondTheGraceWindowRequiresAFreshEnroll() { + assertEquals( + RotationDecision.RE_ENROLL_REQUIRED, + RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(31))), + ) + } + + @Test + fun aZeroGraceDisablesRecoveryEntirely() { + // Mirrors the control plane's `expiredRenewGraceMs = 0` posture (renew.test.ts CP6e). + assertEquals( + RotationDecision.RE_ENROLL_REQUIRED, + RotationPolicy.decide( + state(), + now = NOT_AFTER.plusMillis(1), + expiredRecoveryGrace = Duration.ZERO, + ), + ) + } + + @Test + fun theTerminalStateWinsOverTheFailureBackoff() { + // Beyond grace nothing can ever succeed, so the answer must be the terminal one even while a + // backoff is armed — otherwise the user is told "retrying" forever (the exact failure mode + // the agent's 6380 identical warnings came from). + assertEquals( + RotationDecision.RE_ENROLL_REQUIRED, + RotationPolicy.decide( + state(), + now = NOT_AFTER.plus(Duration.ofDays(31)), + lastFailureAt = NOT_AFTER.plus(Duration.ofDays(31)).minusSeconds(1), + ), + ) + } + + // ── the backoff branch (a failed attempt must not hammer the control plane) ───────────────── + + @Test + fun aRecentFailureBacksOffInsteadOfRetryingImmediately() { + // Every foreground fires a pass; the control plane rate-limits renewals to 30/hour/identity, + // so an un-throttled retry turns one failure into a 429 storm. + val now = RENEW_AFTER.plus(Duration.ofMinutes(1)) + assertEquals( + RotationDecision.BACKING_OFF, + RotationPolicy.decide(state(), now = now, lastFailureAt = now.minus(Duration.ofMinutes(1))), + ) + } + + @Test + fun theBackoffExpiresAndTheRenewIsRetried() { + val now = RENEW_AFTER.plus(Duration.ofHours(1)) + assertEquals( + RotationDecision.RENEW, + RotationPolicy.decide( + state(), + now = now, + lastFailureAt = now.minus(RotationPolicy.DEFAULT_FAILURE_BACKOFF), + ), + ) + } + + @Test + fun aRecentFailureAlsoThrottlesTheRecoveryPath() { + val now = NOT_AFTER.plus(Duration.ofDays(8)) + assertEquals( + RotationDecision.BACKING_OFF, + RotationPolicy.decide(state(), now = now, lastFailureAt = now.minusSeconds(30)), + ) + } + + @Test + fun aFailureNeverTurnsAnIdleLeafIntoAnAttempt() { + // NOT_DUE has no attempt to throttle — reporting BACKING_OFF there would be a lie. + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide( + state(), + now = RENEW_AFTER.minusSeconds(1), + lastFailureAt = RENEW_AFTER.minusSeconds(2), + ), + ) + } + + // ── boundary validation ──────────────────────────────────────────────────────────────────── + + @Test + fun negativeDurationsAreRejectedAtTheBoundary() { + assertThrows(IllegalArgumentException::class.java) { + RotationPolicy.decide(state(), now = RENEW_AFTER, expiredRecoveryGrace = Duration.ofDays(-1)) + } + assertThrows(IllegalArgumentException::class.java) { + RotationPolicy.decide(state(), now = RENEW_AFTER, failureBackoff = Duration.ofMinutes(-1)) + } + } + + @Test + fun theStateCarriesNoCredentialInItsToString() { + // The rotation state is logged/surfaced; it must never be able to carry key or cert bytes. + val rendered = state().toString() + assertTrue(rendered.contains("dev-1"), "the non-secret device id is the only identifier here") + assertFalse(rendered.contains("MII"), "no DER/PEM material may appear in a loggable rendering") + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PromptQueueTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PromptQueueTest.kt new file mode 100644 index 0000000..034c531 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PromptQueueTest.kt @@ -0,0 +1,74 @@ +package wang.yaojia.webterm.api.models + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * Tolerant decode for the W2 queue read (`GET /live-sessions/:id/queue` → `{length, items}`) and for + * the additive `LiveSessionInfo.queueLength` the badge reads. The server is UNTRUSTED here: unknown + * keys, missing keys and wrong-typed items must degrade, never crash — and the queued strings, which + * are raw keyboard bytes, must come back verbatim. + */ +@DisplayName("W2 queue models — tolerant decode + verbatim items") +class PromptQueueTest { + private fun snapshot(json: String): QueueSnapshot? = + LossyDecode.objectOrNull(json.toByteArray(), QueueSnapshot.serializer()) + + @Test + fun `decodes length and items`() { + val s = snapshot("""{"length":2,"items":["first","second"]}""")!! + assertEquals(2, s.length) + assertEquals(listOf("first", "second"), s.items) + } + + @Test + fun `queued items keep control characters and CJK verbatim`() { + //  + TAB + CR + CJK, exactly as the server stored the keystroke bytes. + val s = snapshot("""{"length":1,"items":["\t你好\r"]}""")!! + assertEquals("\t你好\r", s.items.single()) + } + + @Test + fun `an empty queue and unknown keys both decode`() { + val s = snapshot("""{"length":0,"items":[],"futureField":true}""")!! + assertEquals(0, s.length) + assertTrue(s.items.isEmpty()) + } + + @Test + fun `missing fields fall back to an empty queue and a non-object body is null`() { + val s = snapshot("{}")!! + assertEquals(0, s.length) + assertTrue(s.items.isEmpty()) + + assertNull(snapshot("[]")) + assertNull(snapshot("garbage")) + } + + // ── LiveSessionInfo.queueLength (additive optional, src/types.ts:318) ───────────────────── + + private val base = + """{"id":"11111111-2222-3333-4444-555555555555","createdAt":1,"clientCount":0,"exited":false,"cols":80,"rows":24""" + + private fun info(extra: String): LiveSessionInfo? = + LossyDecode.objectOrNull("$base$extra}".toByteArray(), LiveSessionInfo.serializer()) + + @Test + fun `queueLength decodes when present`() { + assertEquals(4, info(""","queueLength":4""")!!.queueLength) + } + + @Test + fun `queueLength is null on a pre-W2 server that omits it`() { + assertNull(info("")!!.queueLength) + } + + @Test + fun `a wrong-typed queueLength does not drop the whole session`() { + // A garbled value must not make a running session invisible — the field degrades to null. + assertNull(info(",\"queueLength\":\"lots\"")!!.queueLength) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientQueueTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientQueueTest.kt new file mode 100644 index 0000000..0c494ac --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientQueueTest.kt @@ -0,0 +1,141 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import java.util.UUID + +/** + * Status → outcome mapping for the W2 prompt queue, read off the server handlers + * (`src/server.ts` ~605/644/654): + * + * POST: 200 `{length}` → [QueueWriteOutcome.Ok] · 409 `{error:"full"}` → `Full` · + * 413 → `TooLarge` · 503 `{error:"queue disabled"}` → `Disabled` · + * 404 `{error:"unknown"|"exited"}` → `SessionGone` · 429 (empty) → `RateLimited` · + * 400/403/anything else → `Rejected(status, safe error)`. + * DELETE: 200 `{length:0}` → `Ok(0)` · 404 → `SessionGone` · 403 → `Rejected`. + * GET: 200 → snapshot · 404 → [ApiClientError.SessionNotFound]. + * + * 429 comes from the shared `QUEUE_RATE_MAX = 20`/min/IP bucket and is a DISTINCT outcome precisely + * so no caller auto-retries into it (the `GitWriteOutcome.RateLimited` precedent). + */ +@DisplayName("W2 queue — status mapping") +class ApiClientQueueTest { + private companion object { + const val BASE = "http://h:3000" + val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555") + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + private val queueUrl = "$BASE/live-sessions/$ID/queue" + + private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull() + + private fun queuePost(status: Int, body: String = "") = + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, status = status, body = body.toByteArray()) + + private fun queueDelete(status: Int, body: String = "") = + transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, status = status, body = body.toByteArray()) + + private suspend fun enqueue(text: String = "do the thing") = client.enqueueFollowup(ID, text, appendEnter = true) + + // ── POST ────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a 200 yields Ok carrying the new depth`() = runTest { + queuePost(200, """{"length":3}""") + assertEquals(QueueWriteOutcome.Ok(3), enqueue()) + } + + @Test + fun `a 200 with a garbled body still succeeds with an unknown depth of zero`() = runTest { + queuePost(200, "not json") + assertEquals(QueueWriteOutcome.Ok(0), enqueue()) + } + + @Test + fun `409 full 413 too-large and 503 disabled are distinct typed outcomes`() = runTest { + queuePost(409, """{"error":"full"}""") + assertEquals(QueueWriteOutcome.Full, enqueue()) + + queuePost(413, """{"error":"text too large"}""") + assertEquals(QueueWriteOutcome.TooLarge, enqueue()) + + queuePost(503, """{"error":"queue disabled"}""") + assertEquals(QueueWriteOutcome.Disabled, enqueue()) + } + + @Test + fun `404 unknown or exited is SessionGone`() = runTest { + queuePost(404, """{"error":"unknown"}""") + assertEquals(QueueWriteOutcome.SessionGone, enqueue()) + + queuePost(404, """{"error":"exited"}""") + assertEquals(QueueWriteOutcome.SessionGone, enqueue()) + } + + @Test + fun `429 from the shared 20-per-minute bucket is RateLimited and nothing is retried`() = runTest { + queuePost(429) + assertEquals(QueueWriteOutcome.RateLimited, enqueue()) + // Exactly ONE request went out — a rate-limited enqueue must never auto-retry. + assertEquals(1, transport.recordedRequests.size) + } + + @Test + fun `400 and 403 surface Rejected with the server's safe message`() = runTest { + queuePost(400, """{"error":"text must be a non-empty string"}""") + assertEquals(QueueWriteOutcome.Rejected(400, "text must be a non-empty string"), enqueue()) + + // The Origin guard 403s with an EMPTY body — message degrades to null, never throws. + queuePost(403) + assertEquals(QueueWriteOutcome.Rejected(403, null), enqueue()) + } + + @Test + fun `an odd non-error status is UnexpectedStatus`() = runTest { + queuePost(302) + assertTrue(errorOf { enqueue() } is ApiClientError.UnexpectedStatus) + } + + @Test + fun `an empty prompt is rejected before any network IO`() = runTest { + assertEquals(ApiClientError.QueueTextEmpty, errorOf { client.enqueueFollowup(ID, "", appendEnter = true) }) + assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty prompt") + } + + // ── DELETE ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun `clearQueue maps 200 404 403 and 429`() = runTest { + queueDelete(200, """{"length":0}""") + assertEquals(QueueWriteOutcome.Ok(0), client.clearQueue(ID)) + + queueDelete(404) + assertEquals(QueueWriteOutcome.SessionGone, client.clearQueue(ID)) + + queueDelete(403) + assertEquals(QueueWriteOutcome.Rejected(403, null), client.clearQueue(ID)) + + queueDelete(429) + assertEquals(QueueWriteOutcome.RateLimited, client.clearQueue(ID)) + } + + // ── GET ─────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `queue maps 404 to SessionNotFound and a garbled 200 to InvalidResponseBody`() = runTest { + transport.queueSuccess(url = queueUrl, status = 404) + assertEquals(ApiClientError.SessionNotFound, errorOf { client.queue(ID) }) + + transport.queueSuccess(url = queueUrl, body = "[]".toByteArray()) + assertEquals(ApiClientError.InvalidResponseBody, errorOf { client.queue(ID) }) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/QueueRouteShapeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/QueueRouteShapeTest.kt new file mode 100644 index 0000000..7c72778 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/QueueRouteShapeTest.kt @@ -0,0 +1,162 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import java.util.UUID + +/** + * Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the W2 prompt-queue trio + * (`src/server.ts` ~605/644/654): + * + * - `GET /live-sessions/:id/queue` — read-only ⇒ MUST NOT carry Origin; + * - `POST /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin + JSON body; + * - `DELETE /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin, and (unlike + * `DELETE /projects/worktree`, the precedent that DOES carry a body) MUST NOT carry a body — the + * server handler reads nothing but `:id` and clears the whole queue. + * + * A route reclassified read↔write turns this red instead of silently shipping either a CSWSH hole or a + * write the server 403s. + * + * The enqueued text is RAW KEYBOARD INPUT for a shell (invariant #9): it must survive the JSON body + * **verbatim** — no trim, no escape-rewrite, no Unicode normalisation, and no client-side `\r` + * (`appendEnter` is a flag the SERVER materialises). + */ +@DisplayName("W2 queue routes — shape, Origin policy, verbatim body") +class QueueRouteShapeTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val ORIGIN = "http://192.168.1.5:3000" + val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555") + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + + private val queueUrl = "$BASE/live-sessions/$ID/queue" + + private fun last(): HttpRequest = transport.recordedRequests.last() + + private fun assertGuarded(r: HttpRequest) = + assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin") + + private fun assertReadOnly(r: HttpRequest) = + assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin") + + /** Parse the recorded request body back out of JSON — proves round-trip byte-exactness without + * pinning any particular escaping strategy. */ + private fun bodyText(r: HttpRequest): String? = + Json.parseToJsonElement(r.body!!.decodeToString()).jsonObject["text"]?.jsonPrimitive?.content + + // ── GET — read-only, no Origin ──────────────────────────────────────────────────────────── + + @Test + fun `queue is a read-only GET with no Origin and no body`() = runTest { + transport.queueSuccess(url = queueUrl, body = """{"length":2,"items":["a","b"]}""".toByteArray()) + + val snapshot = client.queue(ID) + + val r = last() + assertEquals(HttpMethod.GET, r.method) + assertEquals(queueUrl, r.url) + assertReadOnly(r) + assertNull(r.body, "a GET must not carry a body") + assertEquals(2, snapshot.length) + assertEquals(listOf("a", "b"), snapshot.items) + } + + // ── POST — guarded, JSON body ───────────────────────────────────────────────────────────── + + @Test + fun `enqueueFollowup is a guarded POST with a text-appendEnter body`() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + + client.enqueueFollowup(ID, "run the tests", appendEnter = true) + + val r = last() + assertEquals(HttpMethod.POST, r.method) + assertEquals(queueUrl, r.url) + assertGuarded(r) + assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE]) + assertEquals("""{"text":"run the tests","appendEnter":true}""", r.body?.decodeToString()) + } + + @Test + fun `appendEnter false is sent verbatim and the client never appends CR itself`() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + + client.enqueueFollowup(ID, "no enter", appendEnter = false) + + assertEquals("""{"text":"no enter","appendEnter":false}""", last().body?.decodeToString()) + assertEquals("no enter", bodyText(last())) + } + + @Test + fun `control characters and CJK survive the body byte-exactly`() = runTest { + // ESC[A (up-arrow), a literal TAB, ETX (Ctrl-C), CR, a stray NUL, CJK, an emoji, and + // deliberate surrounding whitespace that must NOT be trimmed. + val raw = " \u001B[A\t\u0003 \u0000 \u4F60\u597D\uFF0C\u4E16\u754C \uD83C\uDF0F caf\u00E9\r\n " + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + + client.enqueueFollowup(ID, raw, appendEnter = false) + + assertEquals(raw, bodyText(last()), "queued prompt bytes must travel verbatim (invariant #9)") + } + + // ── DELETE — guarded, and (unlike DELETE /projects/worktree) body-less ──────────────────── + + @Test + fun `clearQueue is a guarded DELETE with NO body`() = runTest { + transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray()) + + client.clearQueue(ID) + + val r = last() + assertEquals(HttpMethod.DELETE, r.method) + assertEquals(queueUrl, r.url) + assertGuarded(r) + assertNull(r.body, "DELETE /live-sessions/:id/queue takes no body (server reads only :id)") + assertFalse(r.headers.containsKey(HeaderName.CONTENT_TYPE), "no body ⇒ no Content-Type") + } + + // ── the batch invariant ─────────────────────────────────────────────────────────────────── + + @Test + fun `only the two writes carry Origin (batch invariant)`() = runTest { + transport.queueSuccess(url = queueUrl, body = """{"length":0,"items":[]}""".toByteArray()) + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray()) + + client.queue(ID) + client.enqueueFollowup(ID, "x", appendEnter = true) + client.clearQueue(ID) + + assertReadOnly(transport.recordedRequests[0]) + assertGuarded(transport.recordedRequests[1]) + assertGuarded(transport.recordedRequests[2]) + assertNotNull(transport.recordedRequests[1].body) + } + + @Test + fun `session id is serialized lowercase in the path`() = runTest { + val upper = UUID.fromString("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE") + val url = "$BASE/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue" + transport.queueSuccess(url = url, body = """{"length":0,"items":[]}""".toByteArray()) + + client.queue(upper) + + assertTrue(last().url.endsWith("/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue")) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/QueuePanel.kt b/android/app/src/main/java/wang/yaojia/webterm/components/QueuePanel.kt new file mode 100644 index 0000000..1970b11 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/QueuePanel.kt @@ -0,0 +1,271 @@ +package wang.yaojia.webterm.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import wang.yaojia.webterm.designsystem.Radius +import wang.yaojia.webterm.designsystem.Spacing +import wang.yaojia.webterm.designsystem.WebTermTheme +import wang.yaojia.webterm.designsystem.WebTermType +import wang.yaojia.webterm.viewmodels.QueueNotice +import wang.yaojia.webterm.viewmodels.QueueUiState +import wang.yaojia.webterm.viewmodels.QueueViewModel + +/** + * # QueueBadge (W2) — the "N queued" pill. + * + * Renders NOTHING for a null (unknown / pre-W2 server) or non-positive depth, so a host without the + * queue feature shows no affordance at all and an empty queue does not shout "0". + * + * The number is always the server's own count — the live `queue` frame or `GET /live-sessions`'s + * `queueLength` — never a locally incremented guess. + */ +@Composable +public fun QueueBadge(depth: Int?, modifier: Modifier = Modifier) { + val pending = depth ?: return + if (pending <= 0) return + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(Radius.sm8)) + .padding(horizontal = Spacing.sm8, vertical = Spacing.xs2), + ) { + Text( + text = "$pending 待发", + style = WebTermType.metaMono, // tabular numerals so the pill does not jitter as N changes + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + ) + } +} + +/** + * # QueuePanel (W2) — queue / review / cancel the follow-up prompt FIFO. + * + * The Android counterpart of `public/queue.ts`: a bottom sheet that queues a prompt the server injects + * into the PTY the next time Claude goes idle, lists what is already pending, and offers the ONE cancel + * the server actually has — cancel-ALL. There is deliberately no per-item cancel affordance, because + * `DELETE /live-sessions/:id/queue` clears the whole FIFO and pretending otherwise would lie. + * + * ### The composed text is raw keyboard input (invariant #9) + * The field's contents go to [onSend] **verbatim** — this file never trims, normalises, or appends a + * `\r`. `appendEnter` is a FLAG on the request, so the SERVER materialises the carriage return + * byte-identically to a keystroke. The send action is enabled by [QueueViewModel.canSend], which refuses + * only a genuinely EMPTY string; whitespace is legitimate shell input. + * + * ### Untrusted text (plan §8) + * Queued items are round-tripped through the server, so they are rendered as INERT [Text] — no linkify, + * no Markdown, no `AnnotatedString` autolink — exactly like every other server-derived string. So is the + * [QueueNotice.Rejected] message, which is the server's own already-sanitized `error` field. + * + * Sheet presentation / detents / IME behaviour are device-QA (plan §7); the state machine is + * `QueueViewModelTest`. + * + * @param onSend the composed prompt, verbatim. The panel clears its field only after invoking this. + * @param onClearAll `DELETE …/queue` — cancel every pending entry. + * @param onRefresh re-read the queue (used when the live depth says the cached list is stale). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +public fun QueuePanel( + state: QueueUiState, + onSend: (String) -> Unit, + onClearAll: () -> Unit, + onDismissNotice: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onRefresh: () -> Unit = {}, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var draft by remember { mutableStateOf("") } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg16, vertical = Spacing.md12), + verticalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = "排队提示词", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + QueueBadge(depth = state.depth) + } + Text( + text = "Claude 下次空闲时,服务器会按顺序把它们送进终端。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + state.notice?.let { notice -> + QueueNoticeRow(notice = notice, onDismiss = onDismissNotice) + } + + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, // VERBATIM: no trim / normalisation anywhere on this path + label = { Text("要排队的提示词") }, + enabled = !state.isBusy, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 6, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + Button( + onClick = { + onSend(draft) // exactly what the user typed + draft = "" + }, + enabled = !state.isBusy && QueueViewModel.canSend(draft), + ) { Text("加入队列") } + TextButton(onClick = onClearAll, enabled = !state.isBusy && state.depth > 0) { + Text("全部取消") + } + } + + HorizontalDivider() + QueuedItems(state = state, onRefresh = onRefresh) + } + } +} + +/** The pending prompts, inert. A stale list offers a re-read rather than contradicting the badge. */ +@Composable +private fun QueuedItems(state: QueueUiState, onRefresh: () -> Unit) { + if (state.isItemsStale) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = "队列已变化。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onRefresh) { Text("刷新") } + } + return + } + if (state.items.isEmpty()) { + Text( + text = "队列是空的。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = QUEUE_LIST_MAX_HEIGHT), + verticalArrangement = Arrangement.spacedBy(Spacing.xs4), + ) { + itemsIndexed(state.items) { index, item -> + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + Text( + text = "${index + 1}.", + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // INERT: round-tripped through the server, so plain Text only (plan §8). + Text( + text = item, + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +/** + * One honest line per failure. A 429 says so plainly and offers NO retry button — the bucket is shared + * (20/min/IP), so a retry only burns the budget a real enqueue needs. + */ +@Composable +private fun QueueNoticeRow(notice: QueueNotice, onDismiss: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8)) + .padding(Spacing.sm8), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = queueNoticeText(notice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismiss) { Text("知道了") } + } +} + +/** + * App-authored copy for each outcome. [QueueNotice.Rejected] is the ONE case that surfaces a server + * string, and it is displayed inertly and verbatim (the server already sanitized it); a null message + * falls back to app copy rather than printing "null". + */ +internal fun queueNoticeText(notice: QueueNotice): String = when (notice) { + QueueNotice.Full -> "队列已满,请先取消一条。" + QueueNotice.TooLarge -> "提示词太长了,请缩短后重试。" + QueueNotice.Disabled -> "这台主机关闭了排队功能。" + QueueNotice.SessionGone -> "会话已退出,无法排队。" + // No retry offered: the 20/min bucket is shared with every other client of this host. + QueueNotice.RateLimited -> "操作过于频繁(每分钟上限 20 次),请稍后再手动重试。" + QueueNotice.Unreachable -> "无法连接主机,队列状态未知。" + is QueueNotice.Rejected -> notice.message ?: "主机拒绝了这次操作。" +} + +/** Keeps the sheet's buttons reachable no matter how long the queue gets. */ +private val QUEUE_LIST_MAX_HEIGHT = Spacing.xxl24 * 8 // 192dp + +// ── Preview ───────────────────────────────────────────────────────────────────── + +@Preview(name = "QueueBadge") +@Composable +private fun QueueBadgePreview() { + WebTermTheme { + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) { + QueueBadge(depth = 3) + QueueBadge(depth = 0) // renders nothing + QueueBadge(depth = null) // renders nothing + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt b/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt index 61e44e9..376444a 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt @@ -113,6 +113,10 @@ private fun TitleLine(row: SessionRow) { ) { StatusBadge(status = row.displayStatus) if (row.isUnread) UnreadDot() + // W2: the pending follow-up depth from `GET /live-sessions`'s `queueLength` (null on a pre-W2 + // server → renders nothing). The badge is the cold/list source; an ATTACHED session's live depth + // comes from the `queue` WS frame instead. + QueueBadge(depth = row.info.queueLength) Text( text = row.title.ifBlank { fallbackLabel(row.info) }, style = MaterialTheme.typography.bodyMedium, @@ -202,7 +206,12 @@ private fun SessionListRowPreview() { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) { SessionListRow( row = SessionRow( - info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L), + info = previewInfo( + status = ClaudeStatus.WORKING, + title = "web-terminal", + lastOutputAt = 2L, + queueLength = 2, + ), displayStatus = DisplayStatus.Working, title = "web-terminal", isUnread = true, @@ -228,6 +237,7 @@ private fun previewInfo( cols: Int = 161, rows: Int = 50, lastOutputAt: Long? = null, + queueLength: Int? = null, ): LiveSessionInfo = LiveSessionInfo( id = UUID.fromString("11111111-2222-4333-8444-555555555555"), createdAt = 1L, @@ -240,4 +250,5 @@ private fun previewInfo( rows = rows, telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L), lastOutputAt = lastOutputAt, + queueLength = queueLength, ) diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt index a5aed4d..b46fd44 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt @@ -1,11 +1,19 @@ package wang.yaojia.webterm.di +import com.google.firebase.messaging.FirebaseMessaging import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.suspendCancellableCoroutine +import wang.yaojia.webterm.push.PushRegistrar import wang.yaojia.webterm.push.PushRegistrarTokenSink import wang.yaojia.webterm.push.PushTokenSink +import wang.yaojia.webterm.wiring.HostPushUnregister +import wang.yaojia.webterm.wiring.PushTokenSource +import javax.inject.Singleton +import kotlin.coroutines.resume /** * Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with @@ -13,10 +21,52 @@ import wang.yaojia.webterm.push.PushTokenSink * `Optional` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)` * — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar] * (self-heal). This is the intended optional-collaborator handoff (no mutable global). + * + * It also binds the two seams the HOST-REMOVAL path needs + * ([HostPushUnregister] + [PushTokenSource]): `PushRegistrar.unregisterHost` had zero call sites, so a + * removed host kept this device's token and kept pushing for a host the user had dropped. */ @Module @InstallIn(SingletonComponent::class) public interface PushModule { @Binds public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink + + public companion object { + /** + * `DELETE /push/fcm-token` for ONE host — the FIRST caller of [PushRegistrar.unregisterHost], + * which was defined and never invoked, so a removed host kept this device's token forever. + * + * Routed through the registrar rather than a fresh `ApiClient` so the removal inherits its + * already-tested best-effort discipline (a `CancellationException` rethrown, any other failure + * swallowed with the token never logged). + */ + @Provides + @Singleton + public fun provideHostPushUnregister( + registrar: PushRegistrar, + ): HostPushUnregister = HostPushUnregister { host, token -> registrar.unregisterHost(host, token) } + + /** + * The current FCM registration token. Suspends on Firebase's own `Task` rather than blocking, and + * answers `null` for every degraded case (no `google-services.json`, uninitialised `FirebaseApp`, + * a failed fetch) so a push-less build still removes hosts cleanly. The token is returned to the + * caller and NEVER logged (plan §8). + */ + @Provides + @Singleton + public fun providePushTokenSource(): PushTokenSource = PushTokenSource { + suspendCancellableCoroutine { continuation -> + val task = runCatching { FirebaseMessaging.getInstance().token }.getOrNull() + if (task == null) { + continuation.resume(null) + return@suspendCancellableCoroutine + } + task + .addOnSuccessListener { token -> continuation.resume(token?.takeIf { it.isNotEmpty() }) } + .addOnFailureListener { continuation.resume(null) } + .addOnCanceledListener { continuation.resume(null) } + } + } + } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt index 0cf3920..5c03996 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt @@ -12,15 +12,22 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import wang.yaojia.webterm.hostregistry.DataStoreHostStore import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore +import wang.yaojia.webterm.hostregistry.DataStoreSessionWatermarkStore import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.LastSessionStore +import wang.yaojia.webterm.hostregistry.SessionWatermarkStore import javax.inject.Singleton /** * Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore. - * The host list ([HostStore], key `"hosts"`) and per-host last-session id ([LastSessionStore], key - * `"lastSessionId."`) use disjoint keys, so they safely share one DataStore file. Secret - * material (the device cert) never lives here — that is the Tink store in [TlsModule]. + * The host list ([HostStore], key `"hosts"`), the per-host last-session id ([LastSessionStore], key + * `"lastSessionId."`) and the unread watermarks ([SessionWatermarkStore], key + * `"unreadWatermarks"`) use disjoint keys, so they safely share one DataStore file. Secret material (the + * device cert, the `webterm_auth` cookie) never lives here in the clear — those are the Tink-sealed + * stores in [TlsModule] / [AuthCookieModule]. + * + * Two `DataStore` instances over one file THROW, so every store here takes the single [provideDataStore] + * singleton; a new store means a new KEY, never a new file. */ @Module @InstallIn(SingletonComponent::class) @@ -43,5 +50,16 @@ public object StorageModule { public fun provideLastSessionStore(dataStore: DataStore): LastSessionStore = DataStoreLastSessionStore(dataStore) + /** + * The durable home of the session-list unread watermarks (`sessionId → last-seen ms`). Until this was + * wired the ledger was memory-only, so the unread dot reset on every process death — precisely when + * it matters, since the product premise is walking away and coming back. + */ + @Provides + @Singleton + public fun provideSessionWatermarkStore( + dataStore: DataStore, + ): SessionWatermarkStore = DataStoreSessionWatermarkStore(dataStore) + private const val DATASTORE_NAME: String = "webterm" } diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt index 3a25ba3..22011bd 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt @@ -16,6 +16,8 @@ import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.TinkCertStore import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore +import wang.yaojia.webterm.wire.HttpTransport +import wang.yaojia.webterm.wiring.CertificateRotationScheduler import javax.inject.Singleton /** @@ -78,4 +80,35 @@ public object TlsModule { @Singleton public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher = repository + + /** + * Silent device-certificate rotation (the iOS `CertificateRotationScheduler` counterpart). Android had + * NO rotation at all, so a device certificate simply expired and stranded the app; the scheduler also + * routes an ALREADY-lapsed leaf to `POST /device/:id/recover`, which iOS cannot do. + * + * ### This provider must stay I/O-free + * The shared `OkHttpClient` and the mTLS [HttpTransport] are taken as [dagger.Lazy] and passed as + * SUPPLIERS, so nothing here builds an `SSLSocketFactory` (AndroidKeyStore + Tink reads) while the + * Hilt graph is assembled — that assembly happens on `Main`, and a previous review caught exactly + * that class of ANR. The scheduler resolves both suppliers inside its own `Dispatchers.IO` hop. + * + * `plainTransport` is deliberately NOT passed: its default builds a fresh identity-free client, and + * that separation is the entire point of the recovery path (no TLS terminator will forward an expired + * client certificate, in any `ssl_verify_client` mode). + */ + @Provides + @Singleton + public fun provideCertificateRotationScheduler( + certStore: CertStore, + recordStore: EnrollmentRecordStore, + cacheRefresher: IdentityCacheRefresher, + client: dagger.Lazy, + httpTransport: dagger.Lazy, + ): CertificateRotationScheduler = CertificateRotationScheduler.create( + certStore = certStore, + recordStore = recordStore, + cacheRefresher = cacheRefresher, + sharedClient = { client.get() }, + mtlsTransport = { httpTransport.get() }, + ) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt index 15f6871..cae2871 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt @@ -47,6 +47,7 @@ import wang.yaojia.webterm.viewmodels.CertPhase import wang.yaojia.webterm.viewmodels.CertSummaryView import wang.yaojia.webterm.viewmodels.ClientCertUiState import wang.yaojia.webterm.viewmodels.ClientCertViewModel +import wang.yaojia.webterm.wiring.RotationOutcome /** * # ClientCertScreen (A27) — device-certificate (mTLS) management. @@ -78,6 +79,7 @@ public fun ClientCertScreen( viewModel: ClientCertViewModel, modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, + rotationOutcome: RotationOutcome? = rememberRotationOutcome(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() // Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load. @@ -117,9 +119,21 @@ public fun ClientCertScreen( onDismissError = viewModel::clearError, modifier = modifier, onBack = onBack, + rotationOutcome = rotationOutcome, ) } +/** + * The last silent-rotation outcome, from the app graph. `null` outside a Hilt application (a `@Preview`), + * and `null` before the first pass — both render nothing. + */ +@Composable +internal fun rememberRotationOutcome(): RotationOutcome? { + val outcomes = rememberAppEnvironment()?.rotationOutcomes() ?: return null + val outcome by outcomes.collectAsStateWithLifecycle() + return outcome +} + /** Stateless body — pure inputs so a [Preview] and any host can drive it without the SAF/VM machinery. */ @Composable public fun ClientCertScreen( @@ -132,6 +146,7 @@ public fun ClientCertScreen( onDismissError: () -> Unit, modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, + rotationOutcome: RotationOutcome? = null, ) { Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column( @@ -159,6 +174,10 @@ public fun ClientCertScreen( state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) } + // Silent rotation is invisible by design; a FAILING one must not be. Only the states + // the user can act on are rendered — a healthy/not-due pass stays quiet. + RotationStatusRow(rotationOutcome) + PassphraseField( passphrase = passphrase, onPassphraseChange = onPassphraseChange, @@ -220,6 +239,45 @@ private fun InstalledCertCard(summary: CertSummaryView) { } } +/** + * The last silent-rotation pass, when it is something the user can act on. + * + * Quiet for the healthy states ([RotationOutcome.NotDue] / [RotationOutcome.Renewed] / + * [RotationOutcome.Recovered] / [RotationOutcome.NotEnrolled] / [RotationOutcome.BackingOff]) — a + * successful invisible renewal should stay invisible. Loud only for the three the user must know about: + * a lapsed certificate past its recovery window, a host that has no control-plane URL recorded, and a + * repeatedly failing renewal. + * + * The failure text is the outcome's error SHAPE (a class name or `Http(status,code)`), which is all the + * scheduler retains — deliberately never `Throwable.message`, which can embed a control-plane URL, a + * response body or credential material (plan §8). + */ +@Composable +private fun RotationStatusRow(outcome: RotationOutcome?) { + val message = rotationStatusText(outcome) ?: return + Text(text = message, style = MaterialTheme.typography.bodySmall, color = WebTermColors.statusStuck) +} + +/** App-authored copy for the actionable rotation outcomes; null = say nothing. */ +internal fun rotationStatusText(outcome: RotationOutcome?): String? = when (outcome) { + null, + RotationOutcome.NotEnrolled, + RotationOutcome.Renewed, + RotationOutcome.Recovered, + is RotationOutcome.NotDue, + is RotationOutcome.BackingOff, + -> null + + is RotationOutcome.ReEnrollRequired -> + "⚠ 设备证书已过期超过可恢复期,自动续期无法完成。请用「自动获取证书」重新登记本机。" + + is RotationOutcome.NotConfigured -> + "⚠ 未记录控制面地址,无法自动续期证书。请重新执行一次「自动获取证书」。" + + is RotationOutcome.Failed -> + "⚠ 证书自动续期失败(${outcome.stage.name.lowercase()}:${outcome.errorShape})。当前证书仍然有效。" +} + @Composable private fun SummaryRow(label: String, value: String) { Row( diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt index 14ed4ec..7207f36 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -37,11 +39,16 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.launch import wang.yaojia.webterm.components.PointerContextMenu import wang.yaojia.webterm.components.SessionListRow @@ -51,9 +58,12 @@ import wang.yaojia.webterm.designsystem.Radius import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.nav.LayoutMode import wang.yaojia.webterm.viewmodels.HostOption +import wang.yaojia.webterm.viewmodels.HostRemovalGateway import wang.yaojia.webterm.viewmodels.SessionListUiState import wang.yaojia.webterm.viewmodels.SessionListViewModel import wang.yaojia.webterm.viewmodels.SessionRow +import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore +import wang.yaojia.webterm.wiring.AppEnvironment import java.util.UUID /** @@ -90,6 +100,14 @@ import java.util.UUID * [LayoutMode.STACK] default means a caller that does not pass it gets today's phone behaviour. * @param onNewSessionInCwd pointer-menu「在当前目录开新会话」→ `attach(null, cwd)` in the row's cwd. * `null` (the default) drops that entry. + * @param watermarkStore the DURABLE unread-watermark home, installed into [viewModel] before its first + * fetch so the dot survives process death. Defaults to the app-graph store + * ([rememberAppEnvironment]); a test/preview passes an + * [InMemoryUnreadWatermarkStore][wang.yaojia.webterm.viewmodels.InMemoryUnreadWatermarkStore]. + * @param hostRemoval the un-pair path (`HostRemover`), likewise installed into [viewModel]. `null` + * removes the affordance entirely, which is the right behaviour for a surface that cannot un-pair. + * @param onForeground fired on every STARTED transition — production runs the silent certificate + * rotation pass here (the app's landing surface is the reliable "we are in the foreground" edge). */ @Composable public fun SessionListScreen( @@ -103,14 +121,26 @@ public fun SessionListScreen( thumbnails: SessionThumbnails? = null, layoutMode: LayoutMode = LayoutMode.STACK, onNewSessionInCwd: ((String) -> Unit)? = null, + watermarkStore: UnreadWatermarkStore? = rememberAppEnvironment()?.unreadWatermarkStore, + hostRemoval: HostRemovalGateway? = rememberAppEnvironment()?.hostRemoval, + onForeground: () -> Unit = rememberCertificateRotationTrigger(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val lifecycleOwner = LocalLifecycleOwner.current + var confirmingRemove by remember { mutableStateOf(false) } // STARTED-scoped poll: fetch→wait→repeat while foregrounded, cancelled on background (plan §6.6). - LaunchedEffect(viewModel, lifecycleOwner) { + // The app-scoped collaborators are installed FIRST, inside the same effect, so the install is + // provably ordered before `poll()`'s first ledger read (a separate LaunchedEffect would only be + // ordered by composition order — true today, but not a property worth depending on). + LaunchedEffect(viewModel, lifecycleOwner, watermarkStore, hostRemoval) { + watermarkStore?.let(viewModel::useWatermarkStore) + hostRemoval?.let(viewModel::useRemovalGateway) lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + // Every foreground return: rotate the device certificate if it is due (idempotent, and + // NotDue costs one store read). Fire-and-forget — it must never delay the list. + onForeground() viewModel.poll() } } @@ -125,6 +155,7 @@ public fun SessionListScreen( onPairHost = onPairHost, onEnroll = onEnroll, onImportCert = onImportCert, + onRemoveHost = if (hostRemoval != null) ({ confirmingRemove = true }) else null, ) }, ) { padding -> @@ -142,8 +173,43 @@ public fun SessionListScreen( thumbnails = thumbnails, layoutMode = layoutMode, onNewSessionInCwd = onNewSessionInCwd, + onDismissRemoveError = viewModel::dismissRemoveError, ) } + + if (confirmingRemove) { + RemoveHostConfirmDialog( + hostName = state.hosts.firstOrNull { it.isActive }?.name.orEmpty(), + onConfirm = { + confirmingRemove = false + scope.launch { viewModel.removeActiveHost() } + }, + onDismiss = { confirmingRemove = false }, + ) + } +} + +/** + * Confirm-gate for un-pairing (plan §8): removal deletes this device's push registration ON the host and + * erases the paired record, the last-session pointer, the saved access cookie and the unread watermarks. + * It is not destructive to the host's SESSIONS — they keep running — and the copy says exactly that so + * the user is not guessing. The device certificate is app-wide and deliberately survives. + */ +@Composable +private fun RemoveHostConfirmDialog(hostName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("移除主机") }, + text = { + Text( + // hostName is user-entered at pairing time; rendered inert like every other stored string. + text = "将移除「$hostName」的配对信息、访问凭证与推送注册(该主机不会再向本机推送通知)。" + + "主机上的会话会继续运行;设备证书不会被删除。", + ) + }, + confirmButton = { TextButton(onClick = onConfirm) { Text("移除") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) } // ── Top bar + host menu ────────────────────────────────────────────────────────────────────────────── @@ -156,6 +222,7 @@ private fun SessionListTopBar( onPairHost: () -> Unit, onEnroll: () -> Unit, onImportCert: () -> Unit, + onRemoveHost: (() -> Unit)?, ) { var menuOpen by remember { mutableStateOf(false) } val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话" @@ -175,13 +242,20 @@ private fun SessionListTopBar( onPairHost = { menuOpen = false; onPairHost() }, onEnroll = { menuOpen = false; onEnroll() }, onImportCert = { menuOpen = false; onImportCert() }, + onRemoveHost = onRemoveHost?.let { remove -> { menuOpen = false; remove() } }, ) } }, ) } -/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */ +/** + * The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. + * + * 「移除此主机」acts on the ACTIVE host only — the un-pair has to drop the unread watermarks of that + * host's sessions, and those ids are only known for the host currently listed. It is confirm-gated by the + * caller, and hidden entirely when no removal path is wired. + */ @Composable private fun HostMenu( expanded: Boolean, @@ -191,6 +265,7 @@ private fun HostMenu( onPairHost: () -> Unit, onEnroll: () -> Unit, onImportCert: () -> Unit, + onRemoveHost: (() -> Unit)?, ) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { for (host in hosts) { @@ -206,6 +281,13 @@ private fun HostMenu( // 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS. DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll) DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert) + if (onRemoveHost != null && hosts.any { it.isActive }) { + HorizontalDivider() + DropdownMenuItem( + text = { Text(text = "移除此主机", color = MaterialTheme.colorScheme.error) }, + onClick = onRemoveHost, + ) + } } } @@ -223,33 +305,106 @@ private fun SessionListBody( thumbnails: SessionThumbnails?, layoutMode: LayoutMode, onNewSessionInCwd: ((String) -> Unit)?, + onDismissRemoveError: () -> Unit, ) { androidx.compose.material3.pulltorefresh.PullToRefreshBox( isRefreshing = state.isRefreshing, onRefresh = onRefresh, modifier = Modifier.fillMaxSize().padding(contentPadding), ) { - when { - state.activeHostId == null -> - EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost) - state.rows.isEmpty() -> - EmptyState( - message = if (state.hasLoadError) "无法加载会话列表。下拉重试。" else "该主机没有运行中的会话。", - actionLabel = "开新会话", - onAction = onNewSession, - ) - else -> SessionList( - state = state, - onOpen = onOpen, - onKill = onKill, - thumbnails = thumbnails, - layoutMode = layoutMode, - onNewSessionInCwd = onNewSessionInCwd, - ) + Column(modifier = Modifier.fillMaxSize()) { + // The un-pair failed and NOTHING was removed (all-or-nothing) — say so above the live list. + if (state.hasRemoveError) RemoveErrorBanner(onDismiss = onDismissRemoveError) + Box(modifier = Modifier.weight(1f)) { + when { + state.activeHostId == null -> + EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost) + state.rows.isEmpty() -> + EmptyState( + message = if (state.hasLoadError) { + "无法加载会话列表。下拉重试。" + } else { + "该主机没有运行中的会话。" + }, + actionLabel = "开新会话", + onAction = onNewSession, + ) + else -> SessionList( + state = state, + onOpen = onOpen, + onKill = onKill, + thumbnails = thumbnails, + layoutMode = layoutMode, + onNewSessionInCwd = onNewSessionInCwd, + ) + } + } } } } +/** "nothing was removed" notice — the host is still paired and the action is safe to retry. */ +@Composable +private fun RemoveErrorBanner(onDismiss: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8)) + .padding(Spacing.md12), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = "移除主机失败,未做任何更改。可以再试一次。", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismiss) { Text("知道了") } + } +} + +// ── The Hilt escape hatch for app-scoped collaborators (see TerminalScreen's palette store) ───────────── + +/** + * The app-scoped [AppEnvironment], resolved from the Hilt `SingletonComponent` — the standard escape + * hatch for a Composable, which cannot take constructor injection. Returns `null` outside a Hilt + * application (a `@Preview` or a plain-Compose test host), so those surfaces degrade to no durable + * watermarks and no un-pair affordance instead of crashing. + * + * Resolving it is `Main`-safe: every heavy collaborator inside is behind `dagger.Lazy` and the instance + * already exists by the time any screen composes (`MainActivity` field-injects it in `onCreate`). + */ +@Composable +internal fun rememberAppEnvironment(): AppEnvironment? { + val application = LocalContext.current.applicationContext + return remember(application) { + runCatching { + EntryPointAccessors + .fromApplication(application, AppEnvironmentEntryPoint::class.java) + .appEnvironment() + }.getOrNull() + } +} + +/** + * The default `onForeground` action: run one silent certificate-rotation pass if due. A no-op outside a + * Hilt application. Fire-and-forget by construction — [AppEnvironment.runCertificateRotationIfDue] + * launches on its own app-scoped IO scope, so nothing here can delay the session list. + */ +@Composable +internal fun rememberCertificateRotationTrigger(): () -> Unit { + val env = rememberAppEnvironment() + return remember(env) { { env?.runCertificateRotationIfDue() } } +} + +/** Reads the app-scoped [AppEnvironment] for [rememberAppEnvironment]. */ +@EntryPoint +@InstallIn(SingletonComponent::class) +internal interface AppEnvironmentEntryPoint { + public fun appEnvironment(): AppEnvironment +} + @Composable private fun SessionList( state: SessionListUiState, diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt index 918b359..ebc2b50 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt @@ -46,8 +46,11 @@ import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import wang.yaojia.webterm.components.AwayDigestView import wang.yaojia.webterm.components.BannerModel import wang.yaojia.webterm.components.DataStoreQuickReplyStore @@ -55,6 +58,8 @@ import wang.yaojia.webterm.components.GateBanner import wang.yaojia.webterm.components.HardwareKeyRouter import wang.yaojia.webterm.components.KeyBar import wang.yaojia.webterm.components.PlanGateSheet +import wang.yaojia.webterm.components.QueueBadge +import wang.yaojia.webterm.components.QueuePanel import wang.yaojia.webterm.components.QuickReply import wang.yaojia.webterm.components.QuickReplyPalette import wang.yaojia.webterm.components.QuickReplyPaletteEditor @@ -65,10 +70,16 @@ import wang.yaojia.webterm.designsystem.LocalReduceMotion import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.WebTermColors import wang.yaojia.webterm.terminalview.RemoteTerminalView +import wang.yaojia.webterm.terminalview.TerminalCopyResult +import wang.yaojia.webterm.viewmodels.QueueGateway +import wang.yaojia.webterm.viewmodels.QueueUiState +import wang.yaojia.webterm.viewmodels.QueueViewModel import wang.yaojia.webterm.wire.GateKind import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wiring.RetainedSessionHolder import wang.yaojia.webterm.wiring.TerminalSessionControllerImpl +import wang.yaojia.webterm.wiring.UiConfigGate +import java.util.UUID /** * The arguments to open a NEW session — the pure output of the "new session in current cwd" decision. @@ -142,6 +153,10 @@ public object NewSessionInCwd { * ([rememberAppQuickReplyStore] — the SAME `DataStore` singleton every other store uses, so * the palette really persists); a test/preview passes an * [InMemoryQuickReplyStore][wang.yaojia.webterm.components.InMemoryQuickReplyStore]. + * @param queueGateway the W2 follow-up-queue seam for this host. `null` hides the queue affordance + * entirely (a surface with no way to reach the host cannot queue). + * @param uiConfigGate the host's `GET /config/ui` policy. `null` leaves `allowAutoMode` at its + * fail-closed `false`, so "approve + auto-accept" is never offered on an unknown host. */ @Composable public fun TerminalScreen( @@ -154,6 +169,8 @@ public fun TerminalScreen( onWarmUp: suspend () -> Unit = {}, onDigestExpand: () -> Unit = {}, quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(), + queueGateway: QueueGateway? = rememberAppQueueGateway(endpoint), + uiConfigGate: UiConfigGate? = rememberAppEnvironment()?.uiConfigGate, ) { val context = LocalContext.current val activity = remember(context) { context.findActivity() } @@ -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. val palette = remember(quickReplyStore) { QuickReplyPalette(quickReplyStore) } 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(null) } + var isSelecting by remember { mutableStateOf(false) } + var copyNotice by remember { mutableStateOf(null) } + LaunchedEffect(copyNotice) { + if (copyNotice != null) { + delay(COPY_NOTICE_MS) + copyNotice = null + } + } + Column(modifier = modifier.fillMaxSize()) { TerminalToolbar( title = title, onNewSessionInCwd = requestNewSession, onEditQuickReplies = { showPaletteEditor = true }, + queueDepth = gateUi.queueDepth, + onOpenQueue = if (queueVm != null && liveSessionId != null && queueUi.isSupported) { + { + showQueuePanel = true + queueScope.launch { queueVm.refresh(liveSessionId) } + } + } else { + null + }, + // Shown only while a selection is live: an OEM whose floating toolbar misbehaves still has a + // reachable Copy, and the action is ALWAYS an explicit user gesture (§8 — terminal bytes reach + // the clipboard on no other path). + onCopySelection = if (isSelecting) { + { + copyNotice = copyResultText(remoteTerminalView?.copySelection()) + remoteTerminalView?.clearSelection() + } + } else { + null + }, ) + copyNotice?.let { notice -> InlineNotice(text = notice) } ReconnectBanner(model = banner, onNewSession = requestNewSession) // Cockpit surfaces (A22): away-digest (once per reconnect) + statusLine telemetry chips. Both hide // themselves when their state is null/empty; server strings render inert (§8). gateUi.digest?.let { AwayDigestView(digest = it, onExpand = onDigestExpand) } // onExpand → A28 timeline (wired) gateUi.telemetry?.let { TelemetryChips(telemetry = it, nowMs = nowMs) } // Tool gate → an inline approve/reject card; plan gates use the ModalBottomSheet below. + // The W1 `status.preview` MUST be passed: without it the card is a name-only bar and the user is + // approving a `Bash` call without seeing the command (the "blind approval" defect). gateUi.gate?.takeIf { it.kind == GateKind.TOOL }?.let { toolGate -> - GateBanner(gate = toolGate, onDecide = gateViewModel::decide) + GateBanner(gate = toolGate, onDecide = gateViewModel::decide, preview = gateUi.preview) + } + // A plan gate on a host that FORBIDS auto-accept-edits degrades to the same two-way card: only + // 批准 (approve + review) and 拒绝 (keep planning) are real affordances there, so offering the + // three-way sheet would advertise a decision the host will not honour. The authoritative drop + // lives in `GateViewModel.decide`; this is the "don't offer it" half. + gateUi.gate?.takeIf { it.kind == GateKind.PLAN && !gateUi.allowAutoMode }?.let { planGate -> + GateBanner(gate = planGate, onDecide = gateViewModel::decide, preview = gateUi.preview) } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { key(generation) { @@ -297,6 +386,12 @@ public fun TerminalScreen( 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) // The host view IS the View to compose: :terminal-view now exposes its own // `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 // 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() @@ -336,12 +435,70 @@ public fun TerminalScreen( ) } - // Plan gate → a three-way ModalBottomSheet overlay. Dismissing (scrim/back) resolves NOTHING — the - // gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in - // GateViewModel.decide. - gateUi.gate?.takeIf { it.kind == GateKind.PLAN }?.let { planGate -> - PlanGateSheet(gate = planGate, onDecide = gateViewModel::decide, onDismiss = {}) + // Plan gate → a three-way ModalBottomSheet overlay, but ONLY on a host that permits auto-accept-edits + // (`GET /config/ui`); otherwise the two-way card above stands in. Dismissing (scrim/back) resolves + // NOTHING — the gate stays held until an explicit decision (PlanGateSheet contract); the epoch + // stale-guard lives in GateViewModel.decide. + gateUi.gate?.takeIf { it.kind == GateKind.PLAN && gateUi.allowAutoMode }?.let { planGate -> + PlanGateSheet( + gate = planGate, + onDecide = gateViewModel::decide, + onDismiss = {}, + preview = gateUi.preview, + ) } + + // W2 queue panel. Every write is one attempt with a typed outcome — the presenter never auto-retries, + // least of all a 429 (the 20/min bucket is shared with every other client of this host). + if (showQueuePanel && queueVm != null && liveSessionId != null) { + QueuePanel( + state = queueUi, + onSend = { text -> queueScope.launch { queueVm.enqueue(liveSessionId, text) } }, + onClearAll = { queueScope.launch { queueVm.clearAll(liveSessionId) } }, + onDismissNotice = queueVm::dismissNotice, + onDismiss = { showQueuePanel = false }, + onRefresh = { queueScope.launch { queueVm.refresh(liveSessionId) } }, + ) + } +} + +/** How long a copy-outcome notice stays on screen. */ +private const val COPY_NOTICE_MS: Long = 2_000L + +/** + * App-authored copy for a [TerminalCopyResult]. The copied TEXT is never rendered or logged (§8) — only + * whether the copy happened. + */ +internal fun copyResultText(result: TerminalCopyResult?): String = when (result) { + TerminalCopyResult.COPIED -> "已复制到剪贴板" + TerminalCopyResult.NOTHING_SELECTED -> "没有选中内容" + TerminalCopyResult.NOTHING_TO_COPY -> "选中的区域是空白" + TerminalCopyResult.CLIPBOARD_UNAVAILABLE -> "系统剪贴板拒绝了这次复制" + null -> "终端还没准备好" +} + +/** A short-lived inert status line (copy outcome). Fixed app copy — never a server or exception string. */ +@Composable +private fun InlineNotice(text: String) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Spacing.md12, vertical = Spacing.xs4), + ) + } +} + +/** + * The W2 queue seam for [endpoint], resolved from the Hilt graph (a Composable cannot be + * constructor-injected). `null` outside a Hilt application, which correctly hides the affordance in a + * `@Preview`. Remembered per endpoint so a recomposition does not mint a new presenter. + */ +@Composable +internal fun rememberAppQueueGateway(endpoint: HostEndpoint): QueueGateway? { + val env = rememberAppEnvironment() + return remember(env, endpoint) { env?.buildQueueGateway(endpoint) } } /** Telemetry-chip staleness tick interval (ms) — re-reads the wall clock so chips grey as a frame ages. */ @@ -358,6 +515,9 @@ private fun TerminalToolbar( title: String, onNewSessionInCwd: () -> Unit, onEditQuickReplies: () -> Unit, + queueDepth: Int? = null, + onOpenQueue: (() -> Unit)? = null, + onCopySelection: (() -> Unit)? = null, ) { Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { Row( @@ -373,6 +533,14 @@ private fun TerminalToolbar( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + // Only while text is selected — the clipboard write is always an explicit gesture (§8). + onCopySelection?.let { copy -> TextButton(onClick = copy) { Text("复制") } } + onOpenQueue?.let { open -> + TextButton(onClick = open) { + Text("排队") + QueueBadge(depth = queueDepth, modifier = Modifier.padding(start = Spacing.xs4)) + } + } TextButton(onClick = onEditQuickReplies) { Text("快捷回复") } TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt index 3faf88f..7f0f61f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt @@ -10,11 +10,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import wang.yaojia.webterm.session.Adopted import wang.yaojia.webterm.session.AwayDigest import wang.yaojia.webterm.session.Digest import wang.yaojia.webterm.session.Exited import wang.yaojia.webterm.session.Gate import wang.yaojia.webterm.session.GateState +import wang.yaojia.webterm.session.Queued import wang.yaojia.webterm.session.SessionEvent import wang.yaojia.webterm.session.Telemetry import wang.yaojia.webterm.wire.ApprovalPreview @@ -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 * control event; none steals from another. Capturing at construction registers the mailbox eagerly, * before the holder calls `controller.start()`, so the first gate is never dropped. + * + * ### `allowAutoMode` fails CLOSED (`GET /config/ui`) + * `approve.mode="acceptEdits"` puts Claude into auto-accept-edits, which an operator can switch off + * host-side (`ALLOW_AUTO_MODE=0` → `GET /config/ui` `{allowAutoMode:false}`). [decide] therefore drops + * [GateDecision.ACCEPT_EDITS] unless [setAutoModeAllowed] has been told the host permits it — the + * default is `false`, so a host that is merely unreachable can never *widen* what the phone may approve. + * This is the authoritative half; the surface additionally stops OFFERING the affordance. + * + * ### It also carries the two cockpit signals nothing else owns + * The `queue` frame ([Queued]) and the adopted session id ([Adopted]) arrive on this same control + * mailbox and used to be discarded here. They live in [GateUiState] because this VM is the retained, + * rotation-surviving cockpit consumer (`RetainedSessionHolder`): a composition-scoped collector would + * both lose them on rotation and leak a fresh orphaned mailbox each time. */ public class GateViewModel( private val controller: TerminalSessionController, @@ -106,12 +121,25 @@ public class GateViewModel( is Gate -> onGate(event.gate) is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry) is Digest -> onDigest(event.digest) - // The session ended: a held gate is no longer decidable — clear it so no stale card lingers. - is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null) - else -> Unit // Output / Connection / Adopted are not cockpit concerns. + // W2: the server's authoritative pending-prompt depth (never a local count). + is Queued -> _uiState.value = _uiState.value.copy(queueDepth = event.length.coerceAtLeast(0)) + // The server-issued id — the only way a freshly spawned session learns what to queue against. + is Adopted -> _uiState.value = _uiState.value.copy(sessionId = event.sessionId) + // The session ended: a held gate is no longer decidable and there is no queue to show. + is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null, queueDepth = null) + else -> Unit // Output / Connection are not cockpit concerns. } } + /** + * Record whether this host permits `approve.mode="acceptEdits"` (`GET /config/ui` + * `{allowAutoMode}`). Called by the screen after a SUCCESSFUL fetch only — a failed fetch must leave + * the fail-closed `false` in place, because the permissive default is the dangerous one. + */ + public fun setAutoModeAllowed(allowed: Boolean) { + _uiState.value = _uiState.value.copy(allowAutoMode = allowed) + } + private fun onGate(gate: GateState?) { // 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. @@ -136,8 +164,12 @@ public class GateViewModel( * an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message. */ public fun decide(decision: GateDecision, epoch: Int) { - val held = _uiState.value.gate ?: return // canDecide line 1: no gate held → drop. - if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop. + val state = _uiState.value + val held = state.gate ?: return // canDecide line 1: no gate held → drop. + if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop. + // The host's own policy outranks the phone: auto-accept-edits is refused unless a successful + // `GET /config/ui` said it is allowed (fail closed — see the class doc). + if (decision == GateDecision.ACCEPT_EDITS && !state.allowAutoMode) return val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop. controller.decideGate(epoch, message) } @@ -182,6 +214,22 @@ public data class GateUiState( val telemetry: StatusTelemetry? = null, /** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */ val digest: AwayDigest? = null, + /** + * W2 pending prompt-queue depth from the live `queue` frame — `null` = unknown (no frame yet, or the + * session exited), `0` = empty. The badge renders only for a positive value. + */ + val queueDepth: Int? = null, + /** + * The SERVER-issued session id (`attached`/`Adopted`), or `null` before the first attach. The queue + * routes are addressed by it, so a freshly spawned session (opened with a null id) can only be + * queued to once this arrives. + */ + val sessionId: String? = null, + /** + * Does this host permit `approve.mode="acceptEdits"` (`GET /config/ui` `{allowAutoMode}`)? + * **Defaults to `false` and is only ever raised by a SUCCESSFUL fetch** — fail closed. + */ + val allowAutoMode: Boolean = false, ) /** diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/QueueViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/QueueViewModel.kt new file mode 100644 index 0000000..519a1ff --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/QueueViewModel.kt @@ -0,0 +1,269 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import wang.yaojia.webterm.api.models.QueueSnapshot +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.api.routes.ApiClient +import wang.yaojia.webterm.api.routes.ApiClientError +import java.util.UUID + +/** + * # QueueViewModel (W2) — the follow-up prompt queue presenter. + * + * Drives the three server routes (`POST` / `GET` / `DELETE /live-sessions/:id/queue`) behind the + * [QueueGateway] seam: queue a prompt Claude will be handed the next time it goes idle, review what is + * pending, and cancel everything. Ports the web client's `public/queue.ts` affordance. + * + * ### The queued text is raw shell input (invariant #9 / byte-shuttle) + * [enqueue] passes [text] to the gateway **verbatim** — no trim, no normalisation, no case folding, and + * **never a client-side `\r`**. `appendEnter` stays a FLAG so the SERVER materialises the carriage + * return, byte-identical to a keystroke. The only refusal is a genuinely EMPTY string (mirroring + * [ApiClientError.QueueTextEmpty], raised before any I/O); a whitespace-only prompt is legitimate shell + * input and is sent as-is. + * + * ### The depth is always the server's number + * [QueueUiState.depth] is only ever assigned from an authoritative source — a write's + * [QueueWriteOutcome.Ok.length], a [refresh] snapshot, or the live `queue` WS frame ([onServerDepth]). + * Nothing here increments a local counter, so the badge can never drift from the FIFO the server holds. + * + * ### Failure honesty (no auto-retry, ever) + * Each [QueueWriteOutcome] maps to its own [QueueNotice] and the pass STOPS there: + * - [QueueNotice.RateLimited] (429, the shared 20/min/IP bucket) is **shown, never retried** — a retry + * loop only burns the budget a real enqueue needs; + * - [QueueNotice.Disabled] (503, `QUEUE_ENABLED=0`) additionally clears [QueueUiState.isSupported], so + * the affordance disappears for this host instead of failing over and over; + * - [QueueNotice.Full] / [QueueNotice.TooLarge] / [QueueNotice.SessionGone] are actionable states, and + * [QueueNotice.Rejected] carries the server's own already-sanitized `error` string, rendered INERT. + * A transport throw becomes [QueueNotice.Unreachable] and the last-good queue is kept — a network blip + * must not make the panel claim the queue is empty. + * + * ### Testability + * A plain presenter (not an `androidx.lifecycle.ViewModel`) so every branch runs under `runTest` with no + * `Dispatchers.Main`/Robolectric. Its Compose surface ([wang.yaojia.webterm.components.QueuePanel]) is + * device-QA (plan §7). + */ +public class QueueViewModel(private val gateway: QueueGateway) { + + private val _uiState = MutableStateFlow(QueueUiState()) + + /** The single snapshot the queue panel + depth badge render from. */ + public val uiState: StateFlow = _uiState.asStateFlow() + + /** + * `GET /live-sessions/:id/queue` — the pending depth plus the queued prompts (verbatim), for the + * "review before cancelling" panel. Read-only and safe to call on every panel open. + */ + public suspend fun refresh(sessionId: UUID) { + _uiState.update { it.copy(isBusy = true) } + val snapshot = try { + gateway.snapshot(sessionId) + } catch (cancel: CancellationException) { + _uiState.update { it.copy(isBusy = false) } + throw cancel + } catch (error: Throwable) { + _uiState.update { it.copy(isBusy = false, notice = noticeFor(error)) } + return + } + publish(snapshot) + } + + /** + * `POST /live-sessions/:id/queue` — queue [text] VERBATIM. An empty [text] is refused locally (no + * network I/O), matching the server's own 400 and [ApiClientError.QueueTextEmpty]; the size cap is + * deliberately NOT pre-checked here (it is server config the client cannot know) and surfaces as + * [QueueNotice.TooLarge]. + * + * @param appendEnter leave `true` so the SERVER appends the `\r`; never pre-append one. + */ + public suspend fun enqueue(sessionId: UUID, text: String, appendEnter: Boolean = true) { + if (!canSend(text)) return + write { gateway.enqueue(sessionId, text, appendEnter) } + } + + /** + * `DELETE /live-sessions/:id/queue` — cancel EVERY pending entry. There is no per-item cancel on the + * server, so the panel must not pretend to offer one. + */ + public suspend fun clearAll(sessionId: UUID) { + write { gateway.clear(sessionId) } + } + + /** + * The live `queue` WS frame ([wang.yaojia.webterm.session.Queued]) — the server's authoritative + * depth. A positive depth that no longer matches the cached [QueueUiState.items] marks them STALE + * (the panel then re-reads rather than showing a list that contradicts the badge); a zero depth is + * unambiguous and empties the list outright. + */ + public fun onServerDepth(length: Int) { + val depth = length.coerceAtLeast(0) + _uiState.update { state -> + if (depth == 0) { + state.copy(depth = 0, items = emptyList(), isItemsStale = false) + } else { + state.copy(depth = depth, isItemsStale = depth != state.items.size) + } + } + } + + /** Dismiss the current [QueueNotice]; the queue contents and depth are untouched. */ + public fun dismissNotice() { + _uiState.update { it.copy(notice = null) } + } + + // ── Internals ──────────────────────────────────────────────────────────────────────────────── + + /** Shared body of the two guarded writes: busy flag → one attempt → typed outcome → NO retry. */ + private suspend fun write(attempt: suspend () -> QueueWriteOutcome) { + _uiState.update { it.copy(isBusy = true, notice = null) } + val outcome = try { + attempt() + } catch (cancel: CancellationException) { + _uiState.update { it.copy(isBusy = false) } + throw cancel + } catch (error: Throwable) { + _uiState.update { it.copy(isBusy = false, notice = noticeFor(error)) } + return + } + apply(outcome) + } + + /** + * Fold one write outcome into the state. On success the depth is the server's number and the cached + * item list is re-read lazily (marked stale unless the queue is now empty). + */ + private fun apply(outcome: QueueWriteOutcome) { + when (outcome) { + is QueueWriteOutcome.Ok -> { + onServerDepth(outcome.length) + _uiState.update { it.copy(isBusy = false, notice = null) } + } + + QueueWriteOutcome.Full -> fail(QueueNotice.Full) + QueueWriteOutcome.TooLarge -> fail(QueueNotice.TooLarge) + QueueWriteOutcome.SessionGone -> fail(QueueNotice.SessionGone) + QueueWriteOutcome.RateLimited -> fail(QueueNotice.RateLimited) + QueueWriteOutcome.Disabled -> { + // A capability answer, not a transient failure: stop offering the affordance on this host. + _uiState.update { it.copy(isBusy = false, isSupported = false, notice = QueueNotice.Disabled) } + } + + is QueueWriteOutcome.Rejected -> fail(QueueNotice.Rejected(outcome.message)) + } + } + + private fun fail(notice: QueueNotice) { + _uiState.update { it.copy(isBusy = false, notice = notice) } + } + + private fun publish(snapshot: QueueSnapshot) { + _uiState.update { + it.copy( + depth = snapshot.length.coerceAtLeast(0), + items = snapshot.items, + isBusy = false, + isItemsStale = false, + notice = null, + ) + } + } + + /** A thrown failure → its notice. A 404 is a real state; everything else is "could not reach". */ + private fun noticeFor(error: Throwable): QueueNotice = when (error) { + is ApiClientError.SessionNotFound -> QueueNotice.SessionGone + else -> QueueNotice.Unreachable + } + + public companion object { + /** + * May [text] be sent? Only EMPTY is refused — whitespace is legitimate keyboard input for a + * shell, so trimming here would silently change what the user typed (invariant #9). + */ + public fun canSend(text: String): Boolean = text.isNotEmpty() + } +} + +/** The queue panel + badge snapshot. [items] is empty when nothing is queued OR nothing was read yet. */ +public data class QueueUiState( + /** The server's pending depth — the "N queued" badge number. Never locally incremented. */ + val depth: Int = 0, + /** The queued prompts in FIFO order, VERBATIM (rendered inert — untrusted round-tripped text). */ + val items: List = emptyList(), + /** A write/read is in flight (the send + cancel buttons disable). */ + val isBusy: Boolean = false, + /** False after a 503 — the host has `QUEUE_ENABLED=0`, so hide the affordance entirely. */ + val isSupported: Boolean = true, + /** [items] no longer matches [depth] (a live frame moved the queue) — re-read before trusting it. */ + val isItemsStale: Boolean = false, + /** The last failure to report, or null. */ + val notice: QueueNotice? = null, +) + +/** + * What went wrong on the last queue operation. Typed (not a status code) because each one demands + * different UI behaviour; [Rejected] carries the server's own sanitized `error` string, which must be + * rendered INERT (plain text, no linkify/markdown — plan §8). + */ +public sealed interface QueueNotice { + /** 409 — the bounded FIFO is at `QUEUE_MAX_ITEMS`; cancel something first. */ + public data object Full : QueueNotice + + /** 413 — over the server's per-item byte cap. */ + public data object TooLarge : QueueNotice + + /** 503 — the queue is switched off on this host. */ + public data object Disabled : QueueNotice + + /** 404 — the session exited or was killed. */ + public data object SessionGone : QueueNotice + + /** 429 — the shared 20/min/IP bucket. Shown and NEVER auto-retried. */ + public data object RateLimited : QueueNotice + + /** A transport failure / unparseable body — the queue state is unknown, the last-good view is kept. */ + public data object Unreachable : QueueNotice + + /** Any other 4xx/5xx, with the server's inert message (null when the body was empty). */ + public data class Rejected(val message: String?) : QueueNotice +} + +/** + * The three queue routes as a seam, so [QueueViewModel] is JVM-testable with no transport. Production is + * [ApiClientQueueGateway] over the per-host [ApiClient]; tests script outcomes. + */ +public interface QueueGateway { + /** `GET /live-sessions/:id/queue` — depth + the queued prompts. Throws for 404 / a garbled body. */ + public suspend fun snapshot(id: UUID): QueueSnapshot + + /** `POST /live-sessions/:id/queue` — [text] passed through VERBATIM. */ + public suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome + + /** `DELETE /live-sessions/:id/queue` — cancel-all (there is no per-item cancel). */ + public suspend fun clear(id: UUID): QueueWriteOutcome +} + +/** + * Production [QueueGateway] over one host's [ApiClient] (which stamps `Origin` on the two writes). + * + * [api] is a SUPPLIER and every call hops to [ioDispatcher], for the reason `ApiClientFactory` documents: + * resolving the shared `HttpTransport` builds the mTLS `OkHttpClient` (AndroidKeyStore + Tink reads), and + * this gateway is driven from a Composable's `rememberCoroutineScope()` — i.e. from `Main`. Building the + * gateway therefore stays free, and no keystore work can land on the UI thread. + */ +public class ApiClientQueueGateway( + private val api: () -> ApiClient, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : QueueGateway { + override suspend fun snapshot(id: UUID): QueueSnapshot = withContext(ioDispatcher) { api().queue(id) } + + override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome = + withContext(ioDispatcher) { api().enqueueFollowup(id, text, appendEnter) } + + override suspend fun clear(id: UUID): QueueWriteOutcome = withContext(ioDispatcher) { api().clearQueue(id) } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt index 10f741f..92036d2 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt @@ -14,6 +14,7 @@ import wang.yaojia.webterm.api.routes.ApiClientError import wang.yaojia.webterm.designsystem.DisplayStatus import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.hostregistry.SessionWatermarkStore import wang.yaojia.webterm.session.TitleSanitizer import wang.yaojia.webterm.session.UnreadLedger import wang.yaojia.webterm.wire.Tunables @@ -25,9 +26,16 @@ import kotlin.time.Duration * * Folds `GET /live-sessions` for the ACTIVE host into a single [collectAsStateWithLifecycle-friendly] * [uiState] snapshot of [SessionRow]s (status badge · telemetry · thumbnail key · cols×rows · sanitized - * title · unread dot), and owns the four list actions: STARTED-scoped [poll], [refresh] - * (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open) and [killSession] - * (optimistic swipe-to-kill). Ports the iOS session-list surface. + * title · unread dot · W2 queue depth), and owns the list actions: STARTED-scoped [poll], [refresh] + * (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open), [killSession] + * (optimistic swipe-to-kill) and [removeActiveHost] (the confirm-gated un-pair). Ports the iOS + * session-list surface. + * + * ### The unread dot is DURABLE + * Watermarks live behind [UnreadWatermarkStore]; production binds [PersistedUnreadWatermarkStore] over + * `:host-registry`'s DataStore store, so the dot survives process death — exactly when it matters, the + * product premise being "walk away and come back". The reducer stays in `UnreadLedger` (`:session-core`): + * this VM only decides WHERE a watermark lives, never how one is computed. * * ### Untrusted server boundary (plan §4 / §8) * The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` → @@ -45,14 +53,16 @@ import kotlin.time.Duration * ### Testability * A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time with * no `Dispatchers.Main`/Robolectric. Its collaborators are seams ([SessionListGatewayFactory], - * [UnreadWatermarkStore]) so the fetch→row mapping, unread-dot logic and optimistic-kill path are all - * JVM-tested; swipe/thumbnail/pull gestures are device-QA (plan §7). + * [UnreadWatermarkStore], [HostRemovalGateway]) so the fetch→row mapping, unread-dot logic, + * optimistic-kill path and the all-or-nothing un-pair are all JVM-tested; swipe/thumbnail/pull gestures + * are device-QA (plan §7). */ public class SessionListViewModel( private val hostStore: HostStore, private val gatewayFactory: SessionListGatewayFactory, - private val watermarkStore: UnreadWatermarkStore = InMemoryUnreadWatermarkStore(), + watermarkStore: UnreadWatermarkStore? = null, private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL, + removalGateway: HostRemovalGateway? = null, ) { private val _uiState = MutableStateFlow(SessionListUiState()) @@ -68,10 +78,59 @@ public class SessionListViewModel( /** The active host id; sticky across polls, defaults to the first host until the user switches. */ private var activeHostId: String? = null - /** Local unread watermarks (persisted through [watermarkStore]); loaded once, lazily. */ + /** Local unread watermarks (persisted through [watermarks]); loaded once, lazily. */ private var ledger: UnreadLedger = UnreadLedger() private var ledgerLoaded = false + /** + * Where the watermarks live. Defaults to memory-only so a test/preview needs no storage; production + * installs the DataStore-backed [PersistedUnreadWatermarkStore] via [useWatermarkStore]. + */ + private var watermarks: UnreadWatermarkStore = watermarkStore ?: InMemoryUnreadWatermarkStore() + + /** True when the store came from the constructor — [useWatermarkStore] must never override it. */ + private var watermarkStoreInstalled: Boolean = watermarkStore != null + + /** Un-pairing collaborator, installable post-construction for the same reason as [watermarks]. */ + private var removal: HostRemovalGateway? = removalGateway + private var removalInstalled: Boolean = removalGateway != null + + // ── Why the two collaborators above are installable AFTER construction ─────────────────────── + // Both are app-scoped Hilt singletons, but this VM is constructed by the nav layer, which does not + // reach the Hilt graph; the SCREEN does (`SessionListScreen` resolves them through a Hilt + // `@EntryPoint` — the same escape hatch `TerminalScreen` already uses for its palette store). + // Constructor injection remains the preferred wiring and always WINS: passing either one makes the + // matching installer a no-op, so a test (or a future nav-side wiring) is never overridden. + + /** + * Install the DURABLE watermark store, ONCE, before the ledger is first read. + * + * Refused (returning false, changing nothing) when a store is already installed OR the ledger has + * already been loaded — swapping stores mid-flight could resurrect watermarks the live ledger already + * dropped, or silently discard a `markSeen` that was persisted elsewhere. + * + * @return true when [store] became this VM's watermark home. + */ + public fun useWatermarkStore(store: UnreadWatermarkStore): Boolean { + if (watermarkStoreInstalled || ledgerLoaded) return false + watermarks = store + watermarkStoreInstalled = true + return true + } + + /** + * Install the host-removal collaborator, ONCE. Without it [removeActiveHost] is a no-op (which is the + * correct behaviour for a preview/test that has no way to un-pair anything). + * + * @return true when [gateway] became this VM's removal path. + */ + public fun useRemovalGateway(gateway: HostRemovalGateway): Boolean { + if (removalInstalled) return false + removal = gateway + removalInstalled = true + return true + } + /** * STARTED-scoped poll loop (plan §6.6): fetch the active host's list, wait [pollInterval], repeat. * Cancellation (app backgrounded) stops it cleanly. Run inside `repeatOnLifecycle(STARTED)`. @@ -107,7 +166,7 @@ public class SessionListViewModel( val row = _uiState.value.rows.firstOrNull { it.id == sessionId } ?: return val at = row.info.lastOutputAt ?: return ledger = ledger.record(sessionId.toString(), at) - runCatching { watermarkStore.save(ledger) } + runCatching { watermarks.save(ledger) } _uiState.update { it.copy(rows = it.rows.map { r -> r.withUnread(ledger) }) } } @@ -134,6 +193,46 @@ public class SessionListViewModel( } } + /** + * Un-pair the ACTIVE host: hand it (and the session ids currently listed for it) to the + * [HostRemovalGateway], which is the ONE place that erases every trace keyed off that host — + * the push registration on the host itself (`DELETE /push/fcm-token`, otherwise the phone keeps + * receiving notifications for a host the user dropped), the paired record, the last-session pointer, + * the `webterm_auth` cookie, and those sessions' unread watermarks. + * + * Only the ACTIVE host is removable, because the live session ids — and therefore the watermarks to + * drop — are only known for the host currently listed. Switching first is one tap. + * + * ALL-OR-NOTHING at the UI level: a failure leaves the host exactly where it was and raises + * [SessionListUiState.hasRemoveError], because a half-removed host is worse than none. On success the + * list re-fetches, so the active host re-resolves to a survivor (or the empty pair-a-host state). + */ + public suspend fun removeActiveHost() { + val gateway = removal ?: return + val hostId = activeHostId ?: return + if (!hostsById.containsKey(hostId)) return + val sessionIds = _uiState.value.rows.map { it.id.toString() } + + try { + gateway.removeHost(hostId, sessionIds) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + _uiState.update { it.copy(hasRemoveError = true) } + return + } + // Forget the pointer so the next fetch resolves the first SURVIVING host instead of re-selecting + // a host that no longer exists. + activeHostId = null + _uiState.update { it.copy(hasRemoveError = false) } + fetch(markRefreshing = true) + } + + /** Dismiss the removal-failure banner (the host is still paired; nothing else changed). */ + public fun dismissRemoveError() { + _uiState.update { it.copy(hasRemoveError = false) } + } + // ── Internals ──────────────────────────────────────────────────────────────────────────────── private suspend fun fetch(markRefreshing: Boolean) { @@ -202,7 +301,7 @@ public class SessionListViewModel( private suspend fun ensureLedgerLoaded() { if (ledgerLoaded) return - ledger = runCatching { watermarkStore.load() }.getOrDefault(UnreadLedger()) + ledger = runCatching { watermarks.load() }.getOrDefault(UnreadLedger()) ledgerLoaded = true } } @@ -234,6 +333,11 @@ public data class SessionListUiState( val isRefreshing: Boolean = false, /** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */ val hasLoadError: Boolean = false, + /** + * The last host removal failed, so the host is STILL paired (all-or-nothing). The screen shows an + * inline notice; nothing was partially erased. + */ + val hasRemoveError: Boolean = false, ) /** @@ -299,7 +403,7 @@ public interface UnreadWatermarkStore { public suspend fun save(ledger: UnreadLedger) } -/** In-memory [UnreadWatermarkStore] (default). Watermarks live for the process only until DataStore lands. */ +/** In-memory [UnreadWatermarkStore] — the test/preview double. Watermarks live for the process only. */ public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()) : UnreadWatermarkStore { @Volatile private var current: UnreadLedger = initial override suspend fun load(): UnreadLedger = current @@ -307,3 +411,46 @@ public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger() current = ledger } } + +/** + * The PRODUCTION [UnreadWatermarkStore]: the ledger-typed seam bridged onto `:host-registry`'s + * DataStore-backed [SessionWatermarkStore], so the unread dot survives process death (the whole point — + * the product premise is walking away and coming back). + * + * The two halves are deliberately typed differently and this adapter is the ONLY join: + * - [UnreadLedger] (`:session-core`) owns the *reducer* — the monotonic `record`, the `isUnread` + * comparison, the tie-broken cap; + * - [SessionWatermarkStore] (`:host-registry`) owns *storage* — validation of ids at rest, the growth + * cap, the JSON blob. + * `:host-registry` may not depend on `:session-core` (nothing points sideways), so neither restates the + * other; `:app` is the one module that sees both. Nothing about the reducer is reimplemented here. + * + * The store applies its own sanitize+cap pass, so [load] can legitimately return FEWER watermarks than + * [save] was given (an invalid id at rest is dropped). That is the correct behaviour, not a bug: the + * ledger simply treats a dropped session as never-seen. + */ +public class PersistedUnreadWatermarkStore( + private val store: SessionWatermarkStore, +) : UnreadWatermarkStore { + override suspend fun load(): UnreadLedger = UnreadLedger(store.loadAll()) + + override suspend fun save(ledger: UnreadLedger) { + store.replaceAll(ledger.watermarks) + } +} + +/** + * Un-pairing a host, as ONE operation (plan §8 — a half-removed host is worse than none). + * + * A single seam rather than five calls from the ViewModel, because the steps span four modules + * (`:api-client` push DELETE, `:host-registry` host/last-session/cookie/watermark stores, + * `:transport-okhttp`'s live cookie jar) and the VM must stay JVM-testable with no transport. + * Production is `HostRemover` in the composition root. + * + * @param sessionIds the host's currently-known live session ids, so their unread watermarks are dropped + * too (watermarks are keyed by SESSION, not host — see `HostRemover`). + */ +public fun interface HostRemovalGateway { + /** Erase every trace of [hostId]. Throws if anything essential failed (the UI then keeps the host). */ + public suspend fun removeHost(hostId: String, sessionIds: List) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt index b2ffeb5..e1fdbd9 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt @@ -1,20 +1,37 @@ package wang.yaojia.webterm.wiring +import android.util.Log import dagger.Lazy import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import wang.yaojia.webterm.api.models.UiConfig import wang.yaojia.webterm.api.pairing.PairingProbeResult import wang.yaojia.webterm.api.pairing.runPairingProbe import wang.yaojia.webterm.hostregistry.AuthCookieRecord import wang.yaojia.webterm.hostregistry.AuthCookieStore +import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.LastSessionStore +import wang.yaojia.webterm.hostregistry.SessionWatermarkStore import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.transport.AuthCookieJar import wang.yaojia.webterm.transport.AuthCookieSnapshot +import wang.yaojia.webterm.transport.authCookieHostKey +import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway +import wang.yaojia.webterm.viewmodels.HostRemovalGateway import wang.yaojia.webterm.viewmodels.PairingProber +import wang.yaojia.webterm.viewmodels.PersistedUnreadWatermarkStore +import wang.yaojia.webterm.viewmodels.QueueGateway import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpRequest @@ -53,6 +70,10 @@ import javax.inject.Singleton * transports (lazy). * - [coldStartPolicy] — the cold-launch route seam (A29). * - the `webterm_auth` cookie jar + its durable store — the optional `WEBTERM_TOKEN` gate. + * - [unreadWatermarkStore] — the DURABLE unread-dot watermarks (was memory-only; reset on process death). + * - [uiConfigGate] — the host's `allowAutoMode` policy, fail-closed (`GET /config/ui`). + * - [hostRemoval] — un-pair a host and erase every trace keyed off it, push registration included. + * - [runCertificateRotationIfDue] / [rotationOutcomes] — silent device-certificate rotation. * Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its * [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder]. */ @@ -81,6 +102,20 @@ public class AppEnvironment @Inject constructor( * under JVM unit tests). */ private val thumbnailRasterizerLazy: Lazy, + /** + * The durable, DataStore-backed home of the unread watermarks (`di/StorageModule`). Exposed to the + * session list through [unreadWatermarkStore]; without it the unread dot resets on process death. + */ + private val sessionWatermarkStore: SessionWatermarkStore, + /** + * Silent device-certificate rotation (`di/TlsModule`). `Lazy` for the same reason as the transports: + * resolving it must not pull AndroidKeyStore/Tink work onto the injection (often `Main`) thread. + */ + private val rotationSchedulerLazy: Lazy, + /** Per-host push registration — the `DELETE /push/fcm-token` half is what [removeHost] finally calls. */ + private val pushUnregister: HostPushUnregister, + /** Resolves this device's current FCM token, or null when push is unavailable/uninitialised. */ + private val pushTokenSource: PushTokenSource, ) { /** One-shot cold-start restore of the persisted session cookie into the live jar (see [warmUp]). */ private val authCookieHydrator = AuthCookieHydrator( @@ -142,6 +177,94 @@ public class AppEnvironment @Inject constructor( rasterizer = thumbnailRasterizerLazy.get(), ) + // ── Unread watermarks (durable) ─────────────────────────────────────────────────────────────── + + /** + * The PRODUCTION unread-watermark store: DataStore-backed, so the session-list dot survives process + * death. The session list installs it via `SessionListViewModel.useWatermarkStore`. + */ + public val unreadWatermarkStore: UnreadWatermarkStore = + PersistedUnreadWatermarkStore(sessionWatermarkStore) + + // ── W2 prompt queue ────────────────────────────────────────────────────────────────────────── + + /** + * The follow-up-queue seam for one host (`POST|GET|DELETE /live-sessions/:id/queue`). Per-host + * because the two writes are `Origin`-guarded and the header is derived from the endpoint. + * + * Cheap + `Main`-safe to build: the `ApiClient` is minted by a SUPPLIER inside the gateway's own + * `Dispatchers.IO` hop, so resolving the shared mTLS `OkHttpClient` never happens on the UI thread + * (the same discipline `sessionThumbnailPipeline` follows). + */ + public fun buildQueueGateway(endpoint: HostEndpoint): QueueGateway = + ApiClientQueueGateway(api = { apiClientFactory.create(endpoint) }) + + // ── GET /config/ui (allowAutoMode) ─────────────────────────────────────────────────────────── + + /** + * The host-policy gate for `approve.mode="acceptEdits"`. One instance for the app so a host is asked + * once per process; it FAILS CLOSED, so an unreachable host never widens what the phone may approve. + */ + public val uiConfigGate: UiConfigGate = UiConfigGate { endpoint -> + apiClientFactory.create(endpoint).uiConfig() + } + + // ── Host removal ───────────────────────────────────────────────────────────────────────────── + + /** + * Un-pair a host and erase EVERY trace keyed off it — including the `DELETE /push/fcm-token` that had + * no caller at all, which is why a removed host kept pushing notifications to this device. See + * [HostRemover] for the ordering rules. + */ + public val hostRemoval: HostRemovalGateway by lazy { + // `by lazy` because the session-list screen reads this on `Main`. Resolving the auth-cookie + // store here is safe by that module's documented contract — `TinkAuthCookieCipher` defers ALL + // AndroidKeyStore/AEAD work to first use — and in practice `warmUp()` has already resolved it + // off-`Main`. Nothing in this block does I/O; the removal itself runs in the caller's coroutine. + HostRemover( + hostStore = hostStore, + lastSessionStore = lastSessionStore, + authCookieStore = authCookieStoreLazy.get(), + watermarkStore = sessionWatermarkStore, + pushUnregister = pushUnregister, + currentPushToken = { pushTokenSource.currentToken() }, + clearLiveCookies = { hostKey -> authCookieJarLazy.get().clear(hostKey) }, + ) + } + + // ── Silent certificate rotation ────────────────────────────────────────────────────────────── + + /** + * App-lifetime scope for the fire-and-forget rotation pass. Deliberately NOT the caller's scope: + * [warmUp] is awaited by the terminal screen's readiness gate, and a renewal's network round-trip + * must never be able to hold the UI behind a spinner. + */ + private val rotationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val rotationTrigger = CertificateRotationTrigger( + scope = rotationScope, + scheduler = { rotationSchedulerLazy.get() }, + onError = { error -> + // Shape only: a rotation error can carry a control-plane URL or response body. + Log.w(ROTATION_TAG, "certificate rotation could not start (${error::class.simpleName})") + }, + ) + + /** + * Run one silent certificate-rotation pass if one is due (the Android counterpart of iOS + * `AppCoordinator.runCertificateRotationIfDue()`). Safe on launch AND on every foreground: the + * scheduler is idempotent, self-coalescing, and `NotDue` costs one store read. Returns immediately. + */ + public fun runCertificateRotationIfDue() { + rotationTrigger.fire() + } + + /** + * The most recent rotation pass's outcome (null before the first pass). Observed by the device-cert + * screen so a silently failing renewal becomes visible instead of only a log line. + */ + public fun rotationOutcomes(): StateFlow = rotationSchedulerLazy.get().lastOutcome + /** * Build the shared `OkHttpClient`, rehydrate the persisted session cookie and first-touch the device * identity OFF `Main` (FIX 3). Resolving either transport builds the one shared client (which reads @@ -159,6 +282,184 @@ public class AppEnvironment @Inject constructor( apiClientFactory.warm() // HttpTransport reuses the SAME already-built client runCatching { identityRepositoryLazy.get().hasInstalledIdentity() } // first AndroidKeyStore/Tink touch } + // Rotate the device certificate if it is due. Fired LAST and fire-and-forget: it needs the warmed + // mTLS stack, and its network round-trip must not be awaited by this function's callers (the + // terminal screen blocks its readiness gate on warmUp). The AndroidKeyStore/Tink reads happen on + // the scheduler's own IO dispatcher, never on `Main`. + runCertificateRotationIfDue() + } + + private companion object { + const val ROTATION_TAG: String = "WebTermRotation" + } +} + +// ── GET /config/ui — the auto-mode host policy ──────────────────────────────────────────────────────── + +/** + * Whether a host permits `approve.mode="acceptEdits"` (`GET /config/ui` → `{ allowAutoMode }`). + * + * `ApiClient.uiConfig()` existed with no caller, so the plan-gate three-way sheet offered + * "approve + auto-accept edits" even on a host whose operator had set `ALLOW_AUTO_MODE=0`. This is the + * client half of that policy, and it **fails CLOSED**: + * - a transport failure, a pre-`/config/ui` server, or an unparseable body all answer `false`; + * - only a SUCCESSFUL fetch is cached, so a host that was merely down is re-asked rather than being + * permanently marked one way or the other; + * - `CancellationException` propagates — a cancelled scope must never be read as a policy answer. + * + * The read-only `GET` carries no `Origin` header (plan §4.3) and no credential of its own; the answer is + * a single boolean, so nothing here has to be redacted. + */ +public class UiConfigGate(private val fetch: suspend (HostEndpoint) -> UiConfig) { + private val mutex = Mutex() + private var cache: Map = emptyMap() + + /** `true` only when [endpoint] positively confirmed it. Never throws except for cancellation. */ + public suspend fun allowsAutoMode(endpoint: HostEndpoint): Boolean { + mutex.withLock { cache[endpoint.baseUrl] }?.let { return it } + val allowed = try { + fetch(endpoint).allowAutoMode + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + return false // FAIL CLOSED — and deliberately NOT cached, so a recovered host is re-asked. + } + mutex.withLock { cache = cache + (endpoint.baseUrl to allowed) } + return allowed + } +} + +// ── Silent certificate rotation: the app-lifecycle trigger ──────────────────────────────────────────── + +/** + * The lifecycle edge that CALLS [CertificateRotationScheduler] — the Android counterpart of iOS + * `AppCoordinator.runCertificateRotationIfDue()`. Without a trigger the scheduler is dead code and a + * device certificate simply expires, stranding the app behind an mTLS handshake it can no longer make. + * + * [fire] launches and RETURNS: the pass does AndroidKeyStore/Tink reads and possibly a control-plane + * round-trip, and no UI path may wait on that. The scheduler is idempotent and self-coalescing, so + * firing on launch and again on every foreground needs no external guard. + * + * Nothing escapes: the scheduler turns a rotation failure into `RotationOutcome.Failed` itself, and + * [onError] only sees the (unexpected) case where the scheduler could not even be resolved. Both report + * the error's SHAPE only — a rotation error can embed a control-plane URL or a response body. + */ +public class CertificateRotationTrigger( + private val scope: CoroutineScope, + private val scheduler: () -> CertificateRotationScheduler, + private val onError: (Throwable) -> Unit = {}, +) { + /** Start a rotation pass in the background. Idempotent by way of the scheduler's single-flight. */ + public fun fire() { + scope.launch { + try { + scheduler().runIfDue() + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + onError(error) + } + } + } +} + +// ── Host removal ────────────────────────────────────────────────────────────────────────────────────── + +/** + * The `DELETE /push/fcm-token` half of [wang.yaojia.webterm.push.PushRegistrar], as a seam so + * [HostRemover] is JVM-testable with no transport. `di/PushModule` binds it to + * `PushRegistrar.unregisterHost` — whose FIRST caller this is. + */ +public fun interface HostPushUnregister { + /** Best-effort: tell [host] to forget [token]. [token] is never logged (plan §8). */ + public suspend fun unregister(host: Host, token: String) +} + +/** This device's current FCM registration token, or null when push is unavailable. */ +public fun interface PushTokenSource { + public suspend fun currentToken(): String? +} + +/** + * Un-pairing a host, as ONE ordered operation. A half-removed host is worse than none, so both the order + * and the failure policy are load-bearing: + * + * 1. **`DELETE /push/fcm-token` FIRST**, while the host record and its `webterm_auth` cookie still + * exist — the call cannot be addressed without the endpoint, nor authenticated without the cookie. + * `PushRegistrar.unregisterHost` had ZERO call sites before this, which is why a dropped host kept + * pushing notifications to the device forever. It is **best-effort**: a host that is switched off + * must still be removable, and the local state is what the user asked us to forget. + * 2. **Then the local state**, in the order that keeps a failure retryable: the session watermarks, the + * last-session pointer, the persisted + live `webterm_auth` cookie, and the paired record LAST. Any + * throw propagates with the host still paired, so the user can simply try again — the UI treats that + * as "nothing was removed" ([HostRemovalGateway]). + * + * ### What is deliberately NOT removed + * - **The device certificate / AndroidKeyStore identity** — it is ONE app-wide mTLS identity shared by + * every host (`IdentityRepository`), not per-host state. Removing it while un-pairing one host would + * silently break every other tunnel host. It has its own explicit affordance on the cert screen. + * - **The quick-reply palette** — global user content, not host state. + * + * ### Watermarks are keyed by SESSION, not host + * `SessionWatermarkStore` maps `sessionId → last-seen`, so there is no host-scoped sweep to run. The + * caller passes the ids it can attribute to this host (its currently-listed sessions); anything it could + * not see is bounded by the store's own 512-entry cap rather than leaking without limit. + */ +public class HostRemover( + private val hostStore: HostStore, + private val lastSessionStore: LastSessionStore, + private val authCookieStore: AuthCookieStore, + private val watermarkStore: SessionWatermarkStore, + private val pushUnregister: HostPushUnregister, + private val currentPushToken: suspend () -> String?, + private val clearLiveCookies: (hostKey: String) -> Unit, + private val log: (String) -> Unit = { Log.w(REMOVER_TAG, it) }, + /** Everything below is storage + network I/O; the caller is a Composable scope on `Main`. */ + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : HostRemovalGateway { + + override suspend fun removeHost(hostId: String, sessionIds: List): Unit = + withContext(ioDispatcher) { + val host = hostStore.loadAll().firstOrNull { it.id == hostId } + ?: return@withContext // already gone → no-op + + unregisterPushBestEffort(host) + + // Local erasure. Ordered so `hostStore.remove` is LAST: an earlier failure leaves the host + // paired, which the UI reports as "nothing removed" and the user can retry. + sessionIds.forEach { watermarkStore.remove(it) } + lastSessionStore.setLastSessionId(null, hostId) + authCookieHostKey(host.endpoint.baseUrl)?.let { hostKey -> + authCookieStore.removeHost(hostKey) + clearLiveCookies(hostKey) // the in-memory jar too, or the credential rides the next dial + } + hostStore.remove(hostId) + } + + /** + * Tell the host to forget this device's push token. Best-effort by design; the token is passed only + * to the call and never logged (plan §8), and a failure is reported by SHAPE. + */ + private suspend fun unregisterPushBestEffort(host: Host) { + val token = try { + currentPushToken() + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + log("push token unavailable while removing a host (${error::class.simpleName})") + null + } ?: return + try { + pushUnregister.unregister(host, token) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + log("push de-registration failed for the removed host (${error::class.simpleName})") + } + } + + private companion object { + const val REMOVER_TAG: String = "WebTermHostRemoval" } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/CertificateRotationScheduler.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/CertificateRotationScheduler.kt new file mode 100644 index 0000000..695c0aa --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/CertificateRotationScheduler.kt @@ -0,0 +1,328 @@ +package wang.yaojia.webterm.wiring + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.api.enroll.RotationDecision +import wang.yaojia.webterm.api.enroll.RotationPolicy +import wang.yaojia.webterm.tlsandroid.CertStore +import wang.yaojia.webterm.tlsandroid.DeviceEnroller +import wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot +import wang.yaojia.webterm.tlsandroid.DeviceRotationStore +import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore +import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher +import wang.yaojia.webterm.transport.OkHttpClientFactory +import wang.yaojia.webterm.transport.OkHttpHttpTransport +import wang.yaojia.webterm.wire.HttpTransport +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference + +/** Which step of a rotation pass an outcome refers to — so a failure names what actually failed. */ +public enum class RotationStage { + /** Reading the installed identity's timing out of the encrypted stores. */ + READ_STATE, + + /** `POST /device/:id/renew` over mTLS with the current (still valid) certificate. */ + RENEW, + + /** `POST /device/:id/recover` over plain HTTPS with the lapsed certificate in the body. */ + RECOVER, +} + +/** + * The total result of one rotation pass. Every case is safe to log and to render in the UI: none of + * them can carry a private key, a certificate, a CSR or a token (see [RotationOutcome.Failed]). + */ +public sealed interface RotationOutcome { + /** No enrolled device identity (fresh install / legacy `.p12`): nothing to rotate. */ + public data object NotEnrolled : RotationOutcome + + /** + * Enrolled, but no control-plane URL is persisted (a record written before it was recorded), so + * there is no host to rotate against. Surfaced rather than guessed — a compiled-in default would + * silently talk to the wrong control plane. A re-enroll records the URL and clears this. + */ + public data class NotConfigured(val deviceId: String) : RotationOutcome + + /** The renew window has not opened yet — the cheap, common case. */ + public data class NotDue(val renewAfter: Instant?) : RotationOutcome + + /** An attempt is due but the previous one failed too recently; the next try is at [retryAt]. */ + public data class BackingOff(val retryAt: Instant) : RotationOutcome + + /** Renewed silently; the new leaf is presented on the next handshake. */ + public data object Renewed : RotationOutcome + + /** An expired leaf was recovered over plain HTTPS; the new leaf is live. */ + public data object Recovered : RotationOutcome + + /** + * TERMINAL — the leaf expired [expiredFor] ago, past the recovery grace. No request can succeed; + * only a fresh enroll can. Reported once per pass and never retried, so one real failure cannot + * become thousands of identical warnings. + */ + public data class ReEnrollRequired(val expiredFor: Duration) : RotationOutcome + + /** + * The pass failed at [stage]. The PRIOR identity is fully live and unchanged (the atomic + * live-pointer flip only happens after a successful re-issuance), so a transient failure is + * recoverable on a later pass. + * + * [errorShape] is deliberately the error's SHAPE — its class name, or `Http(status,code)` for a + * server rejection — and never `Throwable.message`, which can embed a URL, a response body or + * credential material. + */ + public data class Failed(val stage: RotationStage, val errorShape: String) : RotationOutcome +} + +/** + * Silent device-certificate rotation: on a trigger (app launch / foreground) read the installed + * identity's timing, ask the pure [RotationPolicy] what to do, and drive it. This is the + * "one bootstrap enroll, then never again" half of zero-touch — the Android counterpart of iOS + * `ClientTLS/CertificateRotationScheduler.swift`, which Android was missing entirely (so a device + * certificate simply expired and stranded the app). + * + * It goes beyond the iOS version in one way that matters: iOS can only renew, and `/device/:id/renew` + * is authenticated by the very certificate it renews — so an iOS device whose leaf lapses is stuck + * needing a manual re-enroll. This driver routes a lapsed leaf to `/device/:id/recover` instead + * ([RotationStage.RECOVER]), which takes the expired certificate in the request BODY over PLAIN + * HTTPS because no TLS terminator will forward an expired client certificate at all. + * + * ### The one place allowed to know both halves + * `:api-client` must never gain a `:client-tls-android` edge. This file is the composition point that + * knows both: the pure policy/HTTP client on one side, the keystore-backed enroller on the other. + * + * ### Idempotent, single-flight, and honest + * - Safe to call on EVERY foreground: `NotDue` is the cheap common case and costs no I/O beyond one + * store read. + * - A second trigger while a pass is in flight COALESCES onto it (an `AtomicReference` holding the + * in-flight result), so two foregrounds cannot stampede two renewals into a control plane that + * rate-limits them. + * - A failure never throws out of [runIfDue]: it is recorded as [RotationOutcome.Failed], published + * on [lastOutcome], and used to throttle the next pass. The prior identity stays fully live — + * `IdentityRepository`'s atomic live-pointer flip guarantees that, and nothing here weakens it. + * - A cancelled pass releases the in-flight slot, so cancellation cannot wedge rotation for the + * lifetime of the process. + * + * ### Threading + * The class itself is dispatcher-free (so every branch is unit-testable under virtual time); the + * store/network hop is inside the seams [create] builds, which move the AndroidKeyStore + Tink reads + * off `Dispatchers.Main`. + * + * ### Step-up policy does NOT apply here + * The relay's step-up gate lives in `relay-auth`'s `enforce/onUpgrade.ts` — the terminal-session + * WebSocket upgrade. Renewal and recovery are plain control-plane HTTPS routes and consult no + * step-up policy, so a step-up-required host does not block certificate rotation. + */ +public class CertificateRotationScheduler( + /** Current rotation timing + target, or null when nothing is enrolled. May throw on a store fault. */ + private val readState: suspend () -> DeviceRotationSnapshot?, + /** `POST /device/:id/renew` over mTLS against the given control-plane base URL. */ + private val renew: suspend (controlPlaneUrl: String) -> Unit, + /** `POST /device/:id/recover` over PLAIN HTTPS against the given control-plane base URL. */ + private val recover: suspend (controlPlaneUrl: String) -> Unit, + private val now: () -> Instant = { Instant.now() }, + private val expiredRecoveryGrace: Duration = RotationPolicy.DEFAULT_EXPIRED_RECOVERY_GRACE, + private val failureBackoff: Duration = RotationPolicy.DEFAULT_FAILURE_BACKOFF, + private val log: (String) -> Unit = { Log.i(TAG, it) }, +) { + private val outcomes = MutableStateFlow(null) + + /** + * The most recent pass's outcome, or null before the first pass. Observable so a silently failing + * renewal becomes visible state (iOS surfaces the same as `isCertificateRenewalFailing`) instead + * of only a log line. + */ + public val lastOutcome: StateFlow = outcomes.asStateFlow() + + /** Written only by the in-flight pass (single-flight); volatile so any dispatcher sees it. */ + @Volatile + private var lastFailureAt: Instant? = null + + /** Non-null while a pass is running — the shared result concurrent triggers coalesce onto. */ + private val inFlight = AtomicReference?>(null) + + /** + * Run one pass: read timing → decide → renew/recover if due. Idempotent and safe on every + * foreground; never throws for a rotation failure (that is [RotationOutcome.Failed]). Only + * cancellation propagates. + */ + public suspend fun runIfDue(): RotationOutcome { + while (true) { + inFlight.get()?.let { return it.await() } + val pass = CompletableDeferred() + if (!inFlight.compareAndSet(null, pass)) continue // lost the race — join the winner + try { + val outcome = runPass() + outcomes.value = outcome + inFlight.set(null) // release BEFORE completing so a later trigger starts a fresh pass + pass.complete(outcome) + return outcome + } catch (throwable: Throwable) { + // Cancellation (the only thing that reaches here) must not leave a slot that no + // future pass can ever get past. + inFlight.set(null) + pass.completeExceptionally(throwable) + throw throwable + } + } + } + + private suspend fun runPass(): RotationOutcome { + val snapshot = try { + readState() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + return recordFailure(RotationStage.READ_STATE, error) + } ?: return RotationOutcome.NotEnrolled + + val state = snapshot.renewalState + val at = now() + val failedAt = lastFailureAt + val decision = RotationPolicy.decide( + state = state, + now = at, + lastFailureAt = failedAt, + expiredRecoveryGrace = expiredRecoveryGrace, + failureBackoff = failureBackoff, + ) + return when (decision) { + RotationDecision.NOT_DUE -> RotationOutcome.NotDue(state.renewAfter) + RotationDecision.BACKING_OFF -> + // The policy only answers BACKING_OFF when a failure was recorded, so `failedAt` is + // present; fall back to `at` rather than assert, so a logic change cannot crash here. + RotationOutcome.BackingOff((failedAt ?: at).plus(failureBackoff)) + RotationDecision.RENEW -> + attempt(RotationStage.RENEW, snapshot, renew, RotationOutcome.Renewed) + RotationDecision.RECOVER -> + attempt(RotationStage.RECOVER, snapshot, recover, RotationOutcome.Recovered) + RotationDecision.RE_ENROLL_REQUIRED -> reEnrollRequired(snapshot, at) + } + } + + /** + * Run one re-issuance [operation] against the persisted control plane. The operation is passed in + * rather than selected from [stage] so there is no unreachable "stage that is not an attempt" + * branch to get wrong. + */ + private suspend fun attempt( + stage: RotationStage, + snapshot: DeviceRotationSnapshot, + operation: suspend (controlPlaneUrl: String) -> Unit, + success: RotationOutcome, + ): RotationOutcome { + val deviceId = snapshot.renewalState.deviceId + val controlPlaneUrl = snapshot.controlPlaneUrl.trim() + if (controlPlaneUrl.isEmpty()) { + log("rotation: device $deviceId has no persisted control-plane URL; re-enroll to record it") + return RotationOutcome.NotConfigured(deviceId) + } + return try { + operation(controlPlaneUrl) + lastFailureAt = null + log("rotation: device $deviceId certificate ${stage.name.lowercase()} succeeded") + success + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + recordFailure(stage, error) + } + } + + private fun reEnrollRequired(snapshot: DeviceRotationSnapshot, at: Instant): RotationOutcome { + val notAfter = snapshot.renewalState.notAfter + val expiredFor = if (notAfter == null) Duration.ZERO else Duration.between(notAfter, at) + log( + "rotation: device ${snapshot.renewalState.deviceId} certificate expired ${expiredFor.toDays()}d ago, " + + "past the ${expiredRecoveryGrace.toDays()}d recovery window; a fresh enroll is required", + ) + return RotationOutcome.ReEnrollRequired(expiredFor) + } + + /** + * Record + report a failure. The prior identity is untouched, so the next pass can succeed. Only + * the error's SHAPE is kept — never `message`, which can embed a URL, a body or a credential. + */ + private fun recordFailure(stage: RotationStage, error: Exception): RotationOutcome { + lastFailureAt = now() + val shape = errorShapeOf(error) + log("rotation: ${stage.name.lowercase()} failed ($shape); the current certificate stays live") + return RotationOutcome.Failed(stage, shape) + } + + public companion object { + private const val TAG = "CertRotation" + + /** + * The error's non-secret shape. A server rejection keeps its status + uniform `error` code + * (401 = expired beyond grace / untrusted, 403 = revoked or mismatched, 429 = rate limited), + * which is exactly the diagnostic value needed; everything else degrades to the class name. + */ + private fun errorShapeOf(error: Exception): String = when (error) { + is DeviceEnrollmentError.Http -> + "Http(${error.status}${error.code?.let { ",$it" } ?: ""})" + else -> error.javaClass.simpleName + } + + /** + * Production wiring — the single composition point, so a DI module needs one provider. + * + * [mtlsTransport] is the app's shared REST transport, which presents the current device + * certificate; [plainTransport] builds a SEPARATE client with NO client identity, used only by + * the recovery path. That separation is load-bearing: an expired leaf presented on a handshake + * makes nginx answer a bare 400 (it will not forward an expired client cert in any + * `ssl_verify_client` mode), which is the deadlock recovery exists to escape. The plain client + * is built lazily, so the common (renew) path never pays for it. + * + * Store reads and both HTTP calls are hopped onto [ioDispatcher] — the first identity touch + * does synchronous AndroidKeyStore + Tink work and must never run on `Dispatchers.Main`. + * + * [sharedClient] / [mtlsTransport] / [plainTransport] are SUPPLIERS, not values, for the same + * reason [EnrollmentFlowFactory] takes `dagger.Lazy`: resolving the shared client builds the + * mTLS `SSLSocketFactory` (keystore I/O). As suppliers they are invoked only inside the + * [ioDispatcher] hop, so a DI provider for this scheduler is itself I/O-free and can be + * resolved from the main thread without risking an ANR. + */ + public fun create( + certStore: CertStore, + recordStore: EnrollmentRecordStore, + cacheRefresher: IdentityCacheRefresher, + sharedClient: () -> OkHttpClient, + mtlsTransport: () -> HttpTransport, + plainTransport: () -> HttpTransport = { OkHttpHttpTransport(OkHttpClientFactory.create()) }, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + now: () -> Instant = { Instant.now() }, + log: (String) -> Unit = { Log.i(TAG, it) }, + ): CertificateRotationScheduler { + val rotationStore = DeviceRotationStore(certStore, recordStore) + fun enroller(controlPlaneUrl: String, withRecovery: Boolean): DeviceEnroller = + DeviceEnroller( + client = DeviceEnrollmentClient(controlPlaneUrl, mtlsTransport()), + certStore = certStore, + recordStore = recordStore, + sharedClient = sharedClient(), + cacheRefresher = cacheRefresher, + recoveryClient = + if (withRecovery) DeviceEnrollmentClient(controlPlaneUrl, plainTransport()) else null, + ) + return CertificateRotationScheduler( + readState = { withContext(ioDispatcher) { rotationStore.read() } }, + renew = { url -> withContext(ioDispatcher) { enroller(url, withRecovery = false).renew() } }, + recover = { url -> withContext(ioDispatcher) { enroller(url, withRecovery = true).recover() } }, + now = now, + log = log, + ) + } + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateAutoModeTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateAutoModeTest.kt new file mode 100644 index 0000000..2453db6 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateAutoModeTest.kt @@ -0,0 +1,184 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.session.Adopted +import wang.yaojia.webterm.session.Exited +import wang.yaojia.webterm.session.Gate +import wang.yaojia.webterm.session.GateState +import wang.yaojia.webterm.session.Queued +import wang.yaojia.webterm.session.SessionEvent +import wang.yaojia.webterm.wire.ApproveMode +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.GateKind +import wang.yaojia.webterm.wiring.TerminalSessionController + +/** + * [GateViewModel] — the `GET /config/ui` `allowAutoMode` gate plus the two cockpit signals the VM used + * to DROP on the floor (`queue` depth and the adopted session id). + * + * The `allowAutoMode` gate is security-relevant: `approve.mode="acceptEdits"` puts Claude into + * auto-accept-edits, and an operator who set `ALLOW_AUTO_MODE=0` on the host must not be overridden from + * a phone. The gate **fails CLOSED** — auto is refused until a successful fetch says otherwise — because + * the permissive default is the dangerous one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GateAutoModeTest { + + private class FakeController : TerminalSessionController { + private val channel = Channel(Channel.UNLIMITED) + val decisions = mutableListOf>() + + override fun controlEvents(): Flow = channel.receiveAsFlow() + override val output: Flow = emptyFlow() + override fun start() = Unit + override fun sendInput(data: String) = Unit + override fun resize(cols: Int, rows: Int) = Unit + override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit + override fun decideGate(epoch: Int, message: ClientMessage) { + decisions += epoch to message + } + + override fun close() = Unit + fun emit(event: SessionEvent) { + channel.trySend(event) + } + } + + private fun planGate(epoch: Int) = Gate(GateState(kind = GateKind.PLAN, detail = "plan", epoch = epoch)) + + // ── allowAutoMode fails CLOSED ─────────────────────────────────────────────────────────────── + + @Test + fun `auto-accept is refused until the host says it is allowed`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + fake.emit(planGate(1)) + + assertFalse(vm.uiState.value.allowAutoMode) // fail-closed default + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1) + + assertTrue(fake.decisions.isEmpty()) // NOTHING was sent + } + + @Test + fun `the other two plan affordances still work while auto is disabled`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + fake.emit(planGate(1)) + + vm.decide(GateDecision.APPROVE, epoch = 1) + vm.decide(GateDecision.REJECT, epoch = 1) + + assertEquals(2, fake.decisions.size) + assertEquals( + ClientMessage.Approve(mode = ApproveMode.DEFAULT), + fake.decisions[0].second, + ) + assertEquals(ClientMessage.Reject, fake.decisions[1].second) + } + + @Test + fun `auto-accept is sent once the host allows it`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + vm.setAutoModeAllowed(true) + fake.emit(planGate(7)) + + assertTrue(vm.uiState.value.allowAutoMode) + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 7) + + assertEquals( + listOf(7 to ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS)), + fake.decisions.toList(), + ) + } + + @Test + fun `revoking auto mode drops a later auto decision`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + vm.setAutoModeAllowed(true) + vm.setAutoModeAllowed(false) + fake.emit(planGate(3)) + + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 3) + + assertTrue(fake.decisions.isEmpty()) + } + + @Test + fun `the epoch stale-guard still outranks an allowed auto decision`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + vm.setAutoModeAllowed(true) + fake.emit(planGate(2)) + + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1) // the gate on screen was epoch 1 + + assertTrue(fake.decisions.isEmpty()) + } + + // ── The `queue` frame (previously dropped) ─────────────────────────────────────────────────── + + @Test + fun `the queue frame publishes the pending depth`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + assertNull(vm.uiState.value.queueDepth) // unknown before the first frame + + fake.emit(Queued(3)) + assertEquals(3, vm.uiState.value.queueDepth) + + fake.emit(Queued(0)) + assertEquals(0, vm.uiState.value.queueDepth) + } + + // ── The adopted session id (the queue routes need it) ──────────────────────────────────────── + + @Test + fun `the adopted session id is published so a fresh spawn can be queued to`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + assertNull(vm.uiState.value.sessionId) + + fake.emit(Adopted("11111111-2222-4333-8444-555555555555")) + assertEquals("11111111-2222-4333-8444-555555555555", vm.uiState.value.sessionId) + } + + @Test + fun `an exit clears the queue depth along with the gate`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + fake.emit(planGate(1)) + fake.emit(Queued(2)) + + fake.emit(Exited(code = 0)) + + assertNull(vm.uiState.value.gate) + assertNull(vm.uiState.value.queueDepth) // an exited session has no queue to show + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt index c5ed22b..91629b8 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt @@ -141,6 +141,9 @@ class GateViewModelTest { val fake = FakeController() val vm = GateViewModel(fake) vm.bind(backgroundScope) + // `acceptEdits` is gated by the host's `GET /config/ui` `allowAutoMode`, which fails CLOSED — so a + // test about the WIRE MAPPING has to opt in explicitly. The gate itself is `GateAutoModeTest`. + vm.setAutoModeAllowed(true) fake.emit(planGate(3)) vm.decide(GateDecision.APPROVE, epoch = 3) // default review diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/QueueViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/QueueViewModelTest.kt new file mode 100644 index 0000000..1bfcb4f --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/QueueViewModelTest.kt @@ -0,0 +1,245 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.QueueSnapshot +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.api.routes.ApiClientError +import java.util.UUID + +/** + * [QueueViewModel] (W2 follow-up queue) — the write/read side of `POST|GET|DELETE + * /live-sessions/:id/queue`. + * + * The load-bearing properties asserted here are the ones a screenshot cannot show: + * - the prompt reaches the gateway **VERBATIM** (no trim, no normalisation, no client-side `\r`) and + * `appendEnter` stays a FLAG, so the SERVER materialises the carriage return (invariant #9); + * - every [QueueWriteOutcome] maps to its own UI notice, and a **429 is never auto-retried**; + * - a 503 permanently hides the affordance for the host (`isSupported = false`); + * - the authoritative depth comes from the server (`Ok(length)` / the `queue` WS frame), never from a + * local increment. + * The panel's Compose presentation is device-QA (plan §7). + */ +class QueueViewModelTest { + + private companion object { + val SESSION: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + } + + /** Records every call and replays scripted outcomes in order (the last one repeats). */ + private class FakeGateway( + private val snapshots: List = listOf(QueueSnapshot()), + private val writes: List = listOf(QueueWriteOutcome.Ok(1)), + ) : QueueGateway { + val enqueued = mutableListOf>() + val cleared = mutableListOf() + var snapshotCalls: Int = 0 + + override suspend fun snapshot(id: UUID): QueueSnapshot { + val next = snapshots[minOf(snapshotCalls, snapshots.lastIndex)] + snapshotCalls += 1 + if (next is Throwable) throw next + return next as QueueSnapshot + } + + override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome { + enqueued += Triple(id, text, appendEnter) + return writes[minOf(enqueued.size - 1, writes.lastIndex)] + } + + override suspend fun clear(id: UUID): QueueWriteOutcome { + cleared += id + return writes[minOf(cleared.size - 1, writes.lastIndex)] + } + } + + // ── Verbatim discipline (invariant #9) ─────────────────────────────────────────────────────── + + @Test + fun `the prompt reaches the gateway byte-for-byte with appendEnter as a flag`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Ok(2))) + val vm = QueueViewModel(gateway) + + val raw = " run the tests\nthen commit " + vm.enqueue(SESSION, raw) + + assertEquals(1, gateway.enqueued.size) + val (id, text, appendEnter) = gateway.enqueued.single() + assertEquals(SESSION, id) + assertEquals(raw, text) // no trim, no normalisation + assertFalse(text.contains('\r')) // the CR is the SERVER's job + assertTrue(appendEnter) + assertEquals(2, vm.uiState.value.depth) // authoritative depth from Ok(length) + } + + @Test + fun `an empty prompt is never sent`() = runTest { + val gateway = FakeGateway() + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "") + + assertTrue(gateway.enqueued.isEmpty()) + assertFalse(QueueViewModel.canSend("")) + // whitespace IS legitimate shell input — only EMPTY is refused + assertTrue(QueueViewModel.canSend(" ")) + } + + // ── Outcome → UI notice ────────────────────────────────────────────────────────────────────── + + @Test + fun `a full queue is reported and nothing is retried`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Full)) + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "one more") + + assertEquals(QueueNotice.Full, vm.uiState.value.notice) + assertEquals(1, gateway.enqueued.size) + } + + @Test + fun `a rate limit is surfaced and NEVER auto-retried`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.RateLimited)) + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "prompt") + + assertEquals(QueueNotice.RateLimited, vm.uiState.value.notice) + assertEquals(1, gateway.enqueued.size) // exactly one attempt — a retry burns the shared bucket + assertTrue(vm.uiState.value.isSupported) // 429 is transient, not a capability answer + } + + @Test + fun `a 503 hides the affordance for this host`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Disabled)) + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "prompt") + + assertFalse(vm.uiState.value.isSupported) + assertEquals(QueueNotice.Disabled, vm.uiState.value.notice) + } + + @Test + fun `an oversized prompt asks for a shorter one`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.TooLarge))) + vm.enqueue(SESSION, "x".repeat(64)) + assertEquals(QueueNotice.TooLarge, vm.uiState.value.notice) + } + + @Test + fun `a gone session is reported as gone`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.SessionGone))) + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.SessionGone, vm.uiState.value.notice) + } + + @Test + fun `a server rejection carries the server's own inert message`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.Rejected(403, "forbidden")))) + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.Rejected("forbidden"), vm.uiState.value.notice) + } + + @Test + fun `a transport failure surfaces as unreachable and keeps the last-good queue`() = runTest { + val vm = QueueViewModel( + object : QueueGateway { + override suspend fun snapshot(id: UUID): QueueSnapshot = QueueSnapshot(2, listOf("a", "b")) + override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome = + throw java.io.IOException("boom") + + override suspend fun clear(id: UUID): QueueWriteOutcome = QueueWriteOutcome.Ok(0) + }, + ) + + vm.refresh(SESSION) + assertEquals(listOf("a", "b"), vm.uiState.value.items) + + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.Unreachable, vm.uiState.value.notice) + assertEquals(listOf("a", "b"), vm.uiState.value.items) // last-good kept + } + + // ── Read side + cancel-all ─────────────────────────────────────────────────────────────────── + + @Test + fun `refresh publishes the queued prompts verbatim`() = runTest { + val queued = listOf("first prompt", " second\twith tabs") + val vm = QueueViewModel(FakeGateway(snapshots = listOf(QueueSnapshot(2, queued)))) + + vm.refresh(SESSION) + + assertEquals(2, vm.uiState.value.depth) + assertEquals(queued, vm.uiState.value.items) + assertNull(vm.uiState.value.notice) + } + + @Test + fun `a 404 on refresh reports the session gone`() = runTest { + val vm = QueueViewModel(FakeGateway(snapshots = listOf(ApiClientError.SessionNotFound))) + vm.refresh(SESSION) + assertEquals(QueueNotice.SessionGone, vm.uiState.value.notice) + } + + @Test + fun `cancel-all empties the queue and drives the depth from the server`() = runTest { + val gateway = FakeGateway( + snapshots = listOf(QueueSnapshot(2, listOf("a", "b"))), + writes = listOf(QueueWriteOutcome.Ok(0)), + ) + val vm = QueueViewModel(gateway) + vm.refresh(SESSION) + + vm.clearAll(SESSION) + + assertEquals(listOf(SESSION), gateway.cleared) + assertEquals(0, vm.uiState.value.depth) + assertTrue(vm.uiState.value.items.isEmpty()) + } + + // ── The live `queue` WS frame ──────────────────────────────────────────────────────────────── + + @Test + fun `the queue frame is the authoritative depth and invalidates stale items`() = runTest { + val gateway = FakeGateway(snapshots = listOf(QueueSnapshot(1, listOf("a")))) + val vm = QueueViewModel(gateway) + vm.refresh(SESSION) + + vm.onServerDepth(3) // the server drained/accepted entries we did not send + + assertEquals(3, vm.uiState.value.depth) + // The items list is now known-stale: it must NOT claim 1 entry while the depth says 3. + assertTrue(vm.uiState.value.isItemsStale) + } + + @Test + fun `a zero-depth frame clears the item list outright`() = runTest { + val vm = QueueViewModel(FakeGateway(snapshots = listOf(QueueSnapshot(1, listOf("a"))))) + vm.refresh(SESSION) + + vm.onServerDepth(0) + + assertEquals(0, vm.uiState.value.depth) + assertTrue(vm.uiState.value.items.isEmpty()) + assertFalse(vm.uiState.value.isItemsStale) + } + + @Test + fun `dismissing a notice leaves the queue itself untouched`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.Full))) + vm.refresh(SESSION) + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.Full, vm.uiState.value.notice) + + vm.dismissNotice() + + assertNull(vm.uiState.value.notice) + assertEquals(0, vm.uiState.value.depth) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListPersistenceTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListPersistenceTest.kt new file mode 100644 index 0000000..0db023b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListPersistenceTest.kt @@ -0,0 +1,293 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.LiveSessionInfo +import wang.yaojia.webterm.hostregistry.Host +import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore +import wang.yaojia.webterm.session.UnreadLedger +import wang.yaojia.webterm.wire.ClaudeStatus +import java.util.UUID + +/** + * [SessionListViewModel] — the two things that make it stop lying across a process death and a host + * removal: + * 1. the DURABLE watermark seam ([PersistedUnreadWatermarkStore] over `:host-registry`'s + * `SessionWatermarkStore`), including the one-shot [SessionListViewModel.useWatermarkStore] install + * the composition root uses and the rule that it can never swap a store out from under a loaded + * ledger; + * 2. host removal — which must reach the [HostRemovalGateway] with the removed host's live session ids + * (so its watermarks go too) and must NOT drop the row on failure. + * + * The reducer itself ([UnreadLedger]) is NOT retested here — this is only about WHERE the watermark + * lives. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SessionListPersistenceTest { + + private companion object { + const val BASE_A = "http://a:3000" + const val BASE_B = "http://b:3000" + val ID_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + val ID_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666") + + fun host(id: String, name: String, baseUrl: String): Host = + Host.create(id = id, name = name, baseUrl = baseUrl)!! + + fun session(id: UUID, lastOutputAt: Long? = null) = LiveSessionInfo( + id = id, + createdAt = 1_700_000_000_000L, + clientCount = 1, + status = ClaudeStatus.WORKING, + exited = false, + cwd = null, + title = null, + cols = 80, + rows = 24, + telemetry = null, + lastOutputAt = lastOutputAt, + ) + } + + private class FakeHostStore(initial: List) : HostStore { + var hosts: List = initial + override suspend fun loadAll(): List = hosts + override suspend fun upsert(host: Host): List = hosts + override suspend fun remove(id: String): List { + hosts = hosts.filterNot { it.id == id } + return hosts + } + } + + private class FakeGateway(private val list: List) : SessionListGateway { + override suspend fun liveSessions(): List = list + override suspend fun killSession(id: UUID) = Unit + } + + /** + * Stands in for the production `HostRemover`: it OWNS the removal, so it drops the host from the + * store exactly like the real one does (a fake that only records would let the VM's re-resolve pass + * against a host list no device would ever show). + */ + private class FakeRemovalGateway( + private val hosts: FakeHostStore? = null, + private val error: Throwable? = null, + ) : HostRemovalGateway { + val calls = mutableListOf>>() + override suspend fun removeHost(hostId: String, sessionIds: List) { + calls += hostId to sessionIds + error?.let { throw it } + hosts?.remove(hostId) + } + } + + // ── The durable watermark seam ──────────────────────────────────────────────────────────────── + + @Test + fun `a persisted watermark survives a new view model over the same store`() = + runTest(UnconfinedTestDispatcher()) { + val backing = InMemorySessionWatermarkStore() + val store = PersistedUnreadWatermarkStore(backing) + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val gateway = FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) + + val first = SessionListViewModel(hostStore = hosts, gatewayFactory = { gateway }, watermarkStore = store) + first.refresh() + assertTrue(first.uiState.value.rows.single().isUnread) + first.markSeen(ID_1) + assertFalse(first.uiState.value.rows.single().isUnread) + + // A fresh VM over the SAME durable store == the process restarted. + val second = SessionListViewModel(hostStore = hosts, gatewayFactory = { gateway }, watermarkStore = store) + second.refresh() + assertFalse(second.uiState.value.rows.single().isUnread) + } + + @Test + fun `the adapter round-trips the ledger map through the host-registry store`() = runTest { + val backing = InMemorySessionWatermarkStore() + val store = PersistedUnreadWatermarkStore(backing) + + store.save(UnreadLedger(mapOf(ID_1.toString() to 42L))) + + assertEquals(mapOf(ID_1.toString() to 42L), store.load().watermarks) + assertEquals(mapOf(ID_1.toString() to 42L), backing.loadAll()) + } + + @Test + fun `the adapter drops ids the durable store refuses`() = runTest { + val store = PersistedUnreadWatermarkStore(InMemorySessionWatermarkStore()) + + // "not-a-uuid" fails the frozen v4 session-id validation inside the store. + store.save(UnreadLedger(mapOf(ID_1.toString() to 7L, "not-a-uuid" to 9L))) + + assertEquals(mapOf(ID_1.toString() to 7L), store.load().watermarks) + } + + @Test + fun `useWatermarkStore installs the durable store before the first load`() = + runTest(UnconfinedTestDispatcher()) { + val backing = InMemorySessionWatermarkStore() + backing.replaceAll(mapOf(ID_1.toString() to 100L)) // already seen up to 100 + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + ) + + assertTrue(vm.useWatermarkStore(PersistedUnreadWatermarkStore(backing))) + vm.refresh() + + assertFalse(vm.uiState.value.rows.single().isUnread) // the persisted watermark was honoured + } + + @Test + fun `useWatermarkStore never overrides an explicitly injected store`() = + runTest(UnconfinedTestDispatcher()) { + val injected = InMemoryUnreadWatermarkStore(UnreadLedger(mapOf(ID_1.toString() to 100L))) + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + watermarkStore = injected, + ) + + assertFalse(vm.useWatermarkStore(InMemoryUnreadWatermarkStore())) + vm.refresh() + + assertFalse(vm.uiState.value.rows.single().isUnread) // still the injected store's watermark + } + + @Test + fun `useWatermarkStore is refused once the ledger has loaded`() = runTest(UnconfinedTestDispatcher()) { + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + ) + vm.refresh() // loads the ledger from the default in-memory store + + assertFalse(vm.useWatermarkStore(InMemoryUnreadWatermarkStore())) + } + + @Test + fun `a durable store that throws degrades to no watermarks instead of crashing`() = + runTest(UnconfinedTestDispatcher()) { + val exploding = object : UnreadWatermarkStore { + override suspend fun load(): UnreadLedger = throw IllegalStateException("keystore hiccup") + override suspend fun save(ledger: UnreadLedger) = throw IllegalStateException("disk full") + } + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + watermarkStore = exploding, + ) + + vm.refresh() + assertTrue(vm.uiState.value.rows.single().isUnread) // no watermarks → unread, never a crash + vm.markSeen(ID_1) // must not throw either + } + + // ── Host removal ───────────────────────────────────────────────────────────────────────────── + + @Test + fun `removing the active host hands the gateway its live session ids`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A), host("h2", "desktop", BASE_B))) + val removal = FakeRemovalGateway(hosts) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1), session(ID_2))) }, + removalGateway = removal, + ) + vm.refresh() + + vm.removeActiveHost() + + assertEquals( + listOf("h1" to listOf(ID_1.toString(), ID_2.toString())), + removal.calls.toList(), + ) + // The list re-resolves onto the surviving host. + assertEquals("h2", vm.uiState.value.activeHostId) + assertEquals(listOf("h2"), vm.uiState.value.hosts.map { it.id }) + } + + @Test + fun `removing the only host lands on the empty pair-a-host state`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1))) }, + removalGateway = FakeRemovalGateway(hosts), + ) + vm.refresh() + + vm.removeActiveHost() + + assertNull(vm.uiState.value.activeHostId) + assertTrue(vm.uiState.value.rows.isEmpty()) + } + + @Test + fun `a failed removal leaves the host in place rather than half-removing it`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val removal = FakeRemovalGateway(hosts, error = IllegalStateException("push DELETE failed")) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1))) }, + removalGateway = removal, + ) + vm.refresh() + + vm.removeActiveHost() + + assertEquals(1, removal.calls.size) + assertEquals("h1", vm.uiState.value.activeHostId) + assertTrue(vm.uiState.value.hasRemoveError) + } + + @Test + fun `useRemovalGateway enables removal for a view model built without one`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val removal = FakeRemovalGateway(hosts) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1))) }, + ) + vm.refresh() + + vm.removeActiveHost() // no gateway installed yet → no-op + assertTrue(removal.calls.isEmpty()) + assertEquals(1, hosts.hosts.size) + + assertTrue(vm.useRemovalGateway(removal)) + assertFalse(vm.useRemovalGateway(FakeRemovalGateway(hosts))) // one-shot + vm.removeActiveHost() + + assertEquals(listOf("h1" to listOf(ID_1.toString())), removal.calls.toList()) + assertTrue(hosts.hosts.isEmpty()) + } + + @Test + fun `removeActiveHost with no active host is a no-op`() = runTest(UnconfinedTestDispatcher()) { + val removal = FakeRemovalGateway() + val vm = SessionListViewModel( + hostStore = FakeHostStore(emptyList()), + gatewayFactory = { FakeGateway(emptyList()) }, + removalGateway = removal, + ) + vm.refresh() + + vm.removeActiveHost() + + assertTrue(removal.calls.isEmpty()) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SurfaceCopyMappingTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SurfaceCopyMappingTest.kt new file mode 100644 index 0000000..e8eb8a1 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SurfaceCopyMappingTest.kt @@ -0,0 +1,106 @@ +package wang.yaojia.webterm.viewmodels + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.components.queueNoticeText +import wang.yaojia.webterm.screens.copyResultText +import wang.yaojia.webterm.screens.rotationStatusText +import wang.yaojia.webterm.terminalview.TerminalCopyResult +import wang.yaojia.webterm.wiring.RotationOutcome +import wang.yaojia.webterm.wiring.RotationStage +import java.time.Duration +import java.time.Instant + +/** + * The pure copy mappings behind three surfaces whose PIXELS are device-QA but whose *choice of what to + * say* is a correctness property: + * - [queueNoticeText] — every queue failure has its own honest line, and a server-supplied message is + * never allowed to render as the literal "null"; + * - [rotationStatusText] — a silent renewal stays SILENT, and only the three actionable outcomes speak; + * - [copyResultText] — a refused clipboard write says so instead of pretending it copied. + * None of them may leak a secret: no copied text, no `Throwable.message`, no credential. + */ +class SurfaceCopyMappingTest { + + // ── Queue notices ──────────────────────────────────────────────────────────────────────────── + + @Test + fun `every queue notice has its own non-blank line`() { + val notices = listOf( + QueueNotice.Full, + QueueNotice.TooLarge, + QueueNotice.Disabled, + QueueNotice.SessionGone, + QueueNotice.RateLimited, + QueueNotice.Unreachable, + QueueNotice.Rejected("forbidden"), + ) + val texts = notices.map(::queueNoticeText) + + assertTrue(texts.all { it.isNotBlank() }) + assertEquals(notices.size, texts.distinct().size, "each notice needs its own copy: $texts") + } + + @Test + fun `the rate-limit line states the shared limit and offers no retry`() { + val text = queueNoticeText(QueueNotice.RateLimited) + assertTrue(text.contains("20"), text) // the shared 20/min/IP bucket, named honestly + assertTrue(text.contains("手动"), text) // any retry is the USER's, never automatic + } + + @Test + fun `a rejection with no server message falls back to app copy`() { + val text = queueNoticeText(QueueNotice.Rejected(null)) + assertTrue(text.isNotBlank()) + assertFalse(text.contains("null"), text) + } + + @Test + fun `a server message is surfaced verbatim`() { + assertTrue(queueNoticeText(QueueNotice.Rejected("worktree is dirty")).contains("worktree is dirty")) + } + + // ── Rotation status ────────────────────────────────────────────────────────────────────────── + + @Test + fun `a healthy or in-progress rotation says nothing`() { + assertNull(rotationStatusText(null)) + assertNull(rotationStatusText(RotationOutcome.NotEnrolled)) + assertNull(rotationStatusText(RotationOutcome.Renewed)) + assertNull(rotationStatusText(RotationOutcome.Recovered)) + assertNull(rotationStatusText(RotationOutcome.NotDue(Instant.EPOCH))) + assertNull(rotationStatusText(RotationOutcome.BackingOff(Instant.EPOCH))) + } + + @Test + fun `the three actionable outcomes all speak`() { + assertNotNull(rotationStatusText(RotationOutcome.ReEnrollRequired(Duration.ofDays(40)))) + assertNotNull(rotationStatusText(RotationOutcome.NotConfigured("device-1"))) + assertNotNull(rotationStatusText(RotationOutcome.Failed(RotationStage.RENEW, "Http(429)"))) + } + + @Test + fun `a failure line carries the error SHAPE and says the current cert still works`() { + val text = rotationStatusText(RotationOutcome.Failed(RotationStage.RECOVER, "Http(403,revoked)")) + assertNotNull(text) + assertTrue(text!!.contains("Http(403,revoked)"), text) + assertTrue(text.contains("recover"), text) // which STEP failed + assertTrue(text.contains("仍然有效"), text) // the prior identity is untouched + } + + // ── Copy-out ───────────────────────────────────────────────────────────────────────────────── + + @Test + fun `every copy outcome is reported distinctly and a refusal is not called success`() { + val texts = TerminalCopyResult.entries.map(::copyResultText) + copyResultText(null) + + assertTrue(texts.all { it.isNotBlank() }) + assertEquals(texts.size, texts.distinct().size, "each copy outcome needs its own copy: $texts") + // A refused clipboard write must NOT read like a success. + assertFalse(copyResultText(TerminalCopyResult.CLIPBOARD_UNAVAILABLE).contains("已复制")) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationSchedulerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationSchedulerTest.kt new file mode 100644 index 0000000..cb50104 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationSchedulerTest.kt @@ -0,0 +1,331 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.api.enroll.DeviceRenewalState +import wang.yaojia.webterm.api.enroll.RotationPolicy +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.tlsandroid.CertStore +import wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot +import wang.yaojia.webterm.tlsandroid.EnrollmentRecord +import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore +import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher +import wang.yaojia.webterm.tlsandroid.StoredIdentityMetadata +import java.io.IOException +import java.time.Duration +import java.time.Instant + +/** + * The silent-rotation DRIVER: it reads the installed identity's timing, asks the pure `RotationPolicy` + * what to do, and drives the matching call. iOS ships `CertificateRotationScheduler`; Android had no + * counterpart at all, so a device certificate simply died. + * + * The behaviours under test here are the ones that make it safe to fire on every app foreground: + * - it is IDEMPOTENT and single-flight — a second trigger while one pass is in flight coalesces, + * - a failure is SURFACED (observable outcome) and leaves the prior identity fully live, + * - a failure THROTTLES the next attempt instead of hammering a rate-limited control plane, and + * - nothing it logs or surfaces can carry a credential. + */ +class CertificateRotationSchedulerTest { + private companion object { + val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z") + val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z") + const val CP_URL = "https://cp.terminal.yaojia.wang" + + /** A base64-looking string standing in for credential material in an error message. */ + const val LEAKY_SECRET = "MIIBfzCCASWgAwIBAgIUSECRETCSRBYTES" + } + + private val logLines = mutableListOf() + private var clock: Instant = RENEW_AFTER + private var snapshot: DeviceRotationSnapshot? = snapshot() + private var readFault: Exception? = null + private val renewUrls = mutableListOf() + private val recoverUrls = mutableListOf() + private var renewFault: Exception? = null + private var recoverFault: Exception? = null + + /** Optional gate a test can arm to hold `renew` suspended (single-flight coalescing). */ + private var renewGate: CompletableDeferred? = null + + private fun snapshot( + notAfter: Instant? = NOT_AFTER, + renewAfter: Instant? = RENEW_AFTER, + controlPlaneUrl: String = CP_URL, + ): DeviceRotationSnapshot = DeviceRotationSnapshot( + renewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter), + controlPlaneUrl = controlPlaneUrl, + ) + + private fun scheduler(): CertificateRotationScheduler = CertificateRotationScheduler( + readState = { + readFault?.let { throw it } + snapshot + }, + renew = { url -> + renewUrls += url + renewGate?.await() + renewFault?.let { throw it } + }, + recover = { url -> + recoverUrls += url + recoverFault?.let { throw it } + }, + now = { clock }, + log = { logLines += it }, + ) + + // ── the wait branches ────────────────────────────────────────────────────────────────────── + + @Test + fun nothingEnrolledDoesNoWorkAtAll() = runTest { + snapshot = null + + assertEquals(RotationOutcome.NotEnrolled, scheduler().runIfDue()) + + assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty()) + } + + @Test + fun aCertificateInsideItsRenewWindowIsLeftAlone() = runTest { + clock = RENEW_AFTER.minusSeconds(1) + + assertEquals(RotationOutcome.NotDue(RENEW_AFTER), scheduler().runIfDue()) + + assertTrue(renewUrls.isEmpty(), "the common case must be free") + } + + // ── renew ────────────────────────────────────────────────────────────────────────────────── + + @Test + fun aDueCertificateRenewsAgainstThePersistedControlPlane() = runTest { + val subject = scheduler() + + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + + assertEquals(listOf(CP_URL), renewUrls, "renew targets the control plane the device enrolled with") + assertTrue(recoverUrls.isEmpty()) + assertEquals(RotationOutcome.Renewed, subject.lastOutcome.value) + } + + @Test + fun aBlankControlPlaneUrlRefusesToGuessAHost() = runTest { + // A record written before the URL was persisted has no rotation target. Renewing against a + // compiled-in default would talk to somebody else's control plane. + snapshot = snapshot(controlPlaneUrl = " ") + + assertEquals(RotationOutcome.NotConfigured("dev-1"), scheduler().runIfDue()) + + assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty(), "no request without a known host") + } + + // ── recover ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun anExpiredCertificateRecoversAndNeverAttemptsAnMtlsRenew() = runTest { + clock = NOT_AFTER.plus(Duration.ofDays(8)) + + assertEquals(RotationOutcome.Recovered, scheduler().runIfDue()) + + // An expired leaf cannot authenticate its own renewal, so a renew attempt here is not merely + // useless — it is the production deadlock this whole path exists to break. + assertTrue(renewUrls.isEmpty(), "an expired leaf must never be sent to the mTLS renew route") + assertEquals(listOf(CP_URL), recoverUrls) + } + + @Test + fun aCertificateExpiredBeyondTheGraceWindowIsTerminalAndSilent() = runTest { + clock = NOT_AFTER.plus(Duration.ofDays(31)) + + val outcome = scheduler().runIfDue() + + assertEquals(RotationOutcome.ReEnrollRequired(Duration.ofDays(31)), outcome) + assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty(), "no request can succeed — make none") + } + + // ── failure posture ──────────────────────────────────────────────────────────────────────── + + @Test + fun aFailedRenewIsSurfacedAndTheStageIsIdentified() = runTest { + renewFault = DeviceEnrollmentError.Http(429, "rate_limited") + val subject = scheduler() + + val outcome = subject.runIfDue() + + assertEquals(RotationOutcome.Failed(RotationStage.RENEW, "Http(429,rate_limited)"), outcome) + // Observable, not only a log line — iOS surfaces the same as `isCertificateRenewalFailing`. + assertEquals(outcome, subject.lastOutcome.value) + } + + @Test + fun aFailedRecoveryIsSurfacedSeparatelyFromAFailedRenew() = runTest { + clock = NOT_AFTER.plus(Duration.ofDays(8)) + recoverFault = IOException("connection reset") + + assertEquals( + RotationOutcome.Failed(RotationStage.RECOVER, "IOException"), + scheduler().runIfDue(), + ) + } + + @Test + fun aStateReadFaultIsSurfacedRatherThanReportedAsNotEnrolled() = runTest { + // Degrading a Tink/keystore fault to "nothing enrolled" would disable rotation forever. + readFault = IllegalStateException("keystore unavailable") + + assertEquals( + RotationOutcome.Failed(RotationStage.READ_STATE, "IllegalStateException"), + scheduler().runIfDue(), + ) + } + + @Test + fun aFailureThrottlesTheNextPassInsteadOfHammering() = runTest { + renewFault = IOException("connection reset") + val subject = scheduler() + subject.runIfDue() + + clock = RENEW_AFTER.plus(Duration.ofMinutes(1)) + val second = subject.runIfDue() + + assertEquals( + RotationOutcome.BackingOff(RENEW_AFTER.plus(RotationPolicy.DEFAULT_FAILURE_BACKOFF)), + second, + ) + assertEquals(1, renewUrls.size, "the control plane rate-limits renewals — one failure, one call") + } + + @Test + fun aSuccessClearsTheThrottle() = runTest { + renewFault = IOException("connection reset") + val subject = scheduler() + subject.runIfDue() + + renewFault = null + clock = RENEW_AFTER.plus(RotationPolicy.DEFAULT_FAILURE_BACKOFF) + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + // Still due (the fake state does not change), so a following pass attempts again immediately — + // proving the recorded failure was cleared rather than throttling forever. + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + assertEquals(3, renewUrls.size) + } + + // ── single-flight / idempotence ──────────────────────────────────────────────────────────── + + @Test + fun aSecondTriggerWhileOneIsInFlightCoalescesIntoTheSamePass() = runTest { + val gate = CompletableDeferred() + renewGate = gate + val subject = scheduler() + + val first = async { subject.runIfDue() } + val second = async { subject.runIfDue() } + // Let both actually start: the first parks inside `renew`, the second must then find the pass + // already in flight and park on ITS result. Without this the first pass would finish before + // the second even began, and the test would prove nothing. + testScheduler.runCurrent() + gate.complete(Unit) + + assertEquals(RotationOutcome.Renewed, first.await()) + assertEquals(RotationOutcome.Renewed, second.await()) + assertEquals(1, renewUrls.size, "two foreground triggers must not stampede two renewals") + } + + @Test + fun aCancelledPassDoesNotWedgeTheScheduler() = runTest { + val gate = CompletableDeferred() + renewGate = gate + val subject = scheduler() + + val cancelled = launch { subject.runIfDue() } + cancelled.cancel() + cancelled.join() + + // The in-flight slot must have been released, or every later pass would await a deferred that + // can never complete — rotation dead until the process restarts. + renewGate = null + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + } + + // ── credential hygiene ───────────────────────────────────────────────────────────────────── + + @Test + fun neitherTheLogNorTheOutcomeCanCarryACredential() = runTest { + // A real exception message can embed a URL, a body or, as here, cert/CSR material. The driver + // must report the error SHAPE only — never the message. + renewFault = IllegalStateException("csr=$LEAKY_SECRET token=hunter2") + val subject = scheduler() + + val outcome = subject.runIfDue() + + val rendered = "$outcome ${logLines.joinToString(" ")}" + assertFalse(rendered.contains(LEAKY_SECRET), "no certificate/CSR material may be logged or surfaced") + assertFalse(rendered.contains("hunter2"), "no token/passphrase may be logged or surfaced") + assertFalse(rendered.contains("csr="), "not even the message fragment") + assertEquals(RotationOutcome.Failed(RotationStage.RENEW, "IllegalStateException"), outcome) + } + + @Test + fun theSuccessLogNamesTheDeviceAndNothingElse() = runTest { + scheduler().runIfDue() + + assertTrue(logLines.isNotEmpty(), "a silent rotation must still leave a trace") + val rendered = logLines.joinToString(" ") + assertTrue(rendered.contains("dev-1"), "the non-secret device id is what makes the line useful") + assertFalse(rendered.contains("MII"), "no DER/PEM material") + } + + @Test + fun theLastOutcomeStartsEmpty() = runTest { + assertNull(scheduler().lastOutcome.value, "nothing is claimed before a pass has run") + } + + // ── the composition root ─────────────────────────────────────────────────────────────────── + + @Test + fun createBuildsAWorkingSchedulerOverTheRealCollaborators() = runTest { + // `create` is the ONE place that knows both halves (:api-client + :client-tls-android). This + // smoke test runs it over the real DeviceRotationStore / DeviceEnroller types with empty + // stores, so a broken composition root fails here rather than silently at runtime. + val scheduler = CertificateRotationScheduler.create( + certStore = EmptyCertStore, + recordStore = EmptyRecordStore, + cacheRefresher = IdentityCacheRefresher {}, + sharedClient = { OkHttpClient() }, + mtlsTransport = { FakeHttpTransport() }, + plainTransport = { FakeHttpTransport() }, + ioDispatcher = StandardTestDispatcher(testScheduler), + now = { clock }, + log = { logLines += it }, + ) + + assertEquals(RotationOutcome.NotEnrolled, scheduler.runIfDue()) + } + + private object EmptyCertStore : CertStore { + override fun save(metadata: StoredIdentityMetadata): Unit = error("not used") + + override fun load(): StoredIdentityMetadata? = null + + override fun clear(): Unit = error("not used") + } + + private object EmptyRecordStore : EnrollmentRecordStore { + override fun save(record: EnrollmentRecord): Unit = error("not used") + + override fun load(): EnrollmentRecord? = null + + override fun clear(): Unit = error("not used") + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationTriggerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationTriggerTest.kt new file mode 100644 index 0000000..2d77f9f --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationTriggerTest.kt @@ -0,0 +1,89 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant + +/** + * [CertificateRotationTrigger] — the app-lifecycle edge that finally CALLS + * [CertificateRotationScheduler]. Without it the scheduler is dead code and a device certificate simply + * expires, stranding the app (the iOS `AppCoordinator.runCertificateRotationIfDue()` counterpart). + * + * The trigger is deliberately fire-and-forget: `AppEnvironment.warmUp()` is awaited by the terminal + * screen's readiness gate, so a rotation's NETWORK call must never be able to hold the UI. The + * properties asserted here are the ones that make that safe — it launches rather than suspends, it never + * lets a failure escape, and a scheduler that cannot even be constructed does not crash the app. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CertificateRotationTriggerTest { + + /** A scheduler whose store read is scripted; `null` state ⇒ NotEnrolled (no network at all). */ + private fun scheduler( + readState: suspend () -> wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot? = { null }, + ) = CertificateRotationScheduler( + readState = readState, + renew = { }, + recover = { }, + now = { Instant.parse("2026-07-30T00:00:00Z") }, + log = { }, + ) + + @Test + fun `firing runs one rotation pass and publishes its outcome`() = runTest(UnconfinedTestDispatcher()) { + val live = scheduler() + val trigger = CertificateRotationTrigger(scope = this, scheduler = { live }) + + trigger.fire() + + assertEquals(RotationOutcome.NotEnrolled, live.lastOutcome.value) + } + + @Test + fun `a store fault becomes a Failed outcome, never a thrown exception`() = + runTest(UnconfinedTestDispatcher()) { + val live = scheduler(readState = { throw IllegalStateException("keystore unavailable") }) + val trigger = CertificateRotationTrigger(scope = this, scheduler = { live }) + + trigger.fire() // must not throw + + val outcome = live.lastOutcome.value + assertTrue(outcome is RotationOutcome.Failed, "expected Failed, got $outcome") + assertEquals(RotationStage.READ_STATE, (outcome as RotationOutcome.Failed).stage) + // Only the SHAPE is kept — never a message that could embed a URL or credential material. + assertEquals("IllegalStateException", outcome.errorShape) + } + + @Test + fun `a scheduler that cannot be resolved is reported, not crashed`() = runTest(UnconfinedTestDispatcher()) { + val errors = mutableListOf() + val trigger = CertificateRotationTrigger( + scope = this, + scheduler = { throw IllegalStateException("graph not ready") }, + onError = { errors += it }, + ) + + trigger.fire() + + assertEquals(1, errors.size) + } + + @Test + fun `fire never suspends the caller on the rotation itself`() = runTest { + // A scheduler whose read blocks forever: `fire()` must still return immediately. If it awaited the + // pass, this test would hang instead of completing (runTest would report the coroutine as stuck). + val live = scheduler(readState = { kotlinx.coroutines.awaitCancellation() }) + val scope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val trigger = CertificateRotationTrigger(scope = scope, scheduler = { live }) + + trigger.fire() + + assertNull(live.lastOutcome.value) // still in flight, and we got control back + scope.coroutineContext[kotlinx.coroutines.Job]?.cancel() + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt new file mode 100644 index 0000000..9843f2e --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt @@ -0,0 +1,192 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.hostregistry.AuthCookieRecord +import wang.yaojia.webterm.hostregistry.Host +import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore +import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore +import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore +import java.util.UUID + +/** + * [HostRemover] — un-pairing a host must erase EVERY trace keyed off it, in the one order that works. + * + * The bug this closes: `PushRegistrar.unregisterHost` had zero call sites, so a dropped host kept the + * device's FCM token forever and kept pushing notifications for a host the user had removed. + * + * Two rules are asserted because getting either wrong is worse than not removing at all: + * - **the remote DELETE happens FIRST**, while the host record and its `webterm_auth` cookie still + * exist — erase them first and the DELETE can neither be addressed nor authenticated; + * - **local erasure is all-or-nothing and `HostStore.remove` is LAST**, so a failure part-way leaves the + * host paired and the operation retryable, never a ghost with orphaned cookies. + * The remote DELETE itself is BEST-EFFORT: a host that is switched off must still be removable. + */ +class HostRemoverTest { + + private companion object { + const val BASE = "http://laptop.local:3000" + val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + val SESSION_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666") + + fun host(id: String = "h1"): Host = Host.create(id = id, name = "laptop", baseUrl = BASE)!! + } + + private class RecordingHostStore(initial: List) : HostStore { + var hosts: List = initial + var removeError: Throwable? = null + override suspend fun loadAll(): List = hosts + override suspend fun upsert(host: Host): List = hosts + override suspend fun remove(id: String): List { + removeError?.let { throw it } + hosts = hosts.filterNot { it.id == id } + return hosts + } + } + + /** Records the (host, token) pairs handed to the push DELETE — and whether it was called at all. */ + private class RecordingPush(private val error: Throwable? = null) : HostPushUnregister { + val calls = mutableListOf>() + var hostStillPairedAtCallTime: Boolean? = null + var probe: (() -> Boolean)? = null + + override suspend fun unregister(host: Host, token: String) { + hostStillPairedAtCallTime = probe?.invoke() + calls += host.id to token + error?.let { throw it } + } + } + + private suspend fun fixture( + hosts: RecordingHostStore = RecordingHostStore(listOf(host())), + push: RecordingPush = RecordingPush(), + token: String? = "fcm-token", + cookieJarClears: MutableList = mutableListOf(), + ): Triple { + val lastSession = InMemoryLastSessionStore() + val cookies = InMemoryAuthCookieStore() + val watermarks = InMemorySessionWatermarkStore() + lastSession.setLastSessionId(SESSION_1.toString(), "h1") + cookies.upsert( + AuthCookieRecord( + hostKey = "http://laptop.local:3000", + name = "webterm_auth", + value = "secret", + domain = "laptop.local", + path = "/", + expiresAtEpochMillis = Long.MAX_VALUE, + secure = false, + httpOnly = true, + hostOnly = true, + ), + ) + watermarks.replaceAll(mapOf(SESSION_1.toString() to 10L, SESSION_2.toString() to 20L)) + val remover = HostRemover( + hostStore = hosts, + lastSessionStore = lastSession, + authCookieStore = cookies, + watermarkStore = watermarks, + pushUnregister = push, + currentPushToken = { token }, + clearLiveCookies = { key -> cookieJarClears += key }, + log = { }, // android.util.Log is not mocked under JVM unit tests + ) + return Triple(remover, push, Quad(hosts, lastSession, cookies, watermarks)) + } + + private data class Quad( + val hosts: RecordingHostStore, + val lastSession: InMemoryLastSessionStore, + val cookies: InMemoryAuthCookieStore, + val watermarks: InMemorySessionWatermarkStore, + ) + + @Test + fun `removing a host erases every trace keyed off it`() = runTest { + val jarClears = mutableListOf() + val (remover, push, stores) = fixture(cookieJarClears = jarClears) + + remover.removeHost("h1", listOf(SESSION_1.toString(), SESSION_2.toString())) + + assertEquals(listOf("h1" to "fcm-token"), push.calls.toList()) // the DELETE finally happens + assertTrue(stores.hosts.hosts.isEmpty()) + assertNull(stores.lastSession.lastSessionId("h1")) + assertTrue(stores.cookies.loadAll().isEmpty()) + assertEquals(listOf("http://laptop.local:3000"), jarClears) // live jar too, not just at rest + assertTrue(stores.watermarks.loadAll().isEmpty()) + } + + @Test + fun `the remote DELETE runs while the host is still paired`() = runTest { + val hosts = RecordingHostStore(listOf(host())) + val push = RecordingPush() + push.probe = { hosts.hosts.isNotEmpty() } + val (remover, _, _) = fixture(hosts = hosts, push = push) + + remover.removeHost("h1", emptyList()) + + assertTrue(push.hostStillPairedAtCallTime == true) + } + + @Test + fun `an unreachable host is still removable`() = runTest { + val push = RecordingPush(error = java.io.IOException("host is off")) + val (remover, _, stores) = fixture(push = push) + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertTrue(stores.hosts.hosts.isEmpty()) // best-effort DELETE must not block the removal + } + + @Test + fun `with no push token the local state is still fully erased`() = runTest { + val push = RecordingPush() + val (remover, _, stores) = fixture(push = push, token = null) + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertTrue(push.calls.isEmpty()) // nothing to DELETE + assertTrue(stores.hosts.hosts.isEmpty()) + assertNull(stores.lastSession.lastSessionId("h1")) + } + + @Test + fun `a failing local erase leaves the host paired so the user can retry`() = runTest { + val hosts = RecordingHostStore(listOf(host())) + hosts.removeError = IllegalStateException("datastore write failed") + val (remover, _, stores) = fixture(hosts = hosts) + + assertThrows(IllegalStateException::class.java) { + kotlinx.coroutines.runBlocking { remover.removeHost("h1", listOf(SESSION_1.toString())) } + } + + assertEquals(1, stores.hosts.hosts.size) // still paired → retryable, never a ghost + } + + @Test + fun `an unknown host id is a no-op that touches nothing`() = runTest { + val push = RecordingPush() + val (remover, _, stores) = fixture(push = push) + + remover.removeHost("nope", listOf(SESSION_1.toString())) + + assertTrue(push.calls.isEmpty()) + assertEquals(1, stores.hosts.hosts.size) + assertFalse(stores.watermarks.loadAll().isEmpty()) // watermarks untouched + } + + @Test + fun `only the named sessions lose their watermarks`() = runTest { + val (remover, _, stores) = fixture() + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertEquals(mapOf(SESSION_2.toString() to 20L), stores.watermarks.loadAll()) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/UiConfigGateTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/UiConfigGateTest.kt new file mode 100644 index 0000000..104b673 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/UiConfigGateTest.kt @@ -0,0 +1,85 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.UiConfig +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * [UiConfigGate] — the client half of `GET /config/ui`'s `allowAutoMode`. + * + * `ApiClient.uiConfig()` was implemented and never called, so the plan-gate sheet offered + * "approve + auto-accept edits" even on a host whose operator had switched `ALLOW_AUTO_MODE` off. The + * gate closes that, and the ONE rule that matters is that **failure fails CLOSED**: an unreachable host, + * a pre-`/config/ui` server, or a garbled body must all mean "auto is not allowed", because the + * permissive default is the dangerous one. + */ +class UiConfigGateTest { + + private companion object { + val HOST_A: HostEndpoint = HostEndpoint.fromBaseUrl("http://a:3000")!! + val HOST_B: HostEndpoint = HostEndpoint.fromBaseUrl("http://b:3000")!! + } + + @Test + fun `auto mode is allowed when the host says so`() = runTest { + var calls = 0 + val gate = UiConfigGate { calls += 1; UiConfig(allowAutoMode = true) } + + assertTrue(gate.allowsAutoMode(HOST_A)) + assertEquals(1, calls) + } + + @Test + fun `auto mode is refused when the host disabled it`() = runTest { + val gate = UiConfigGate { UiConfig(allowAutoMode = false) } + assertFalse(gate.allowsAutoMode(HOST_A)) + } + + @Test + fun `a failed fetch fails CLOSED`() = runTest { + val gate = UiConfigGate { throw java.io.IOException("host unreachable") } + assertFalse(gate.allowsAutoMode(HOST_A)) + } + + @Test + fun `a successful answer is cached per host`() = runTest { + val seen = mutableListOf() + val gate = UiConfigGate { endpoint -> + seen += endpoint.baseUrl + UiConfig(allowAutoMode = endpoint == HOST_A) + } + + assertTrue(gate.allowsAutoMode(HOST_A)) + assertTrue(gate.allowsAutoMode(HOST_A)) // served from cache + assertFalse(gate.allowsAutoMode(HOST_B)) // a DIFFERENT host is fetched, not inherited + + assertEquals(listOf(HOST_A.baseUrl, HOST_B.baseUrl), seen.toList()) + } + + @Test + fun `a failure is NOT cached so a host that comes back is re-asked`() = runTest { + var attempt = 0 + val gate = UiConfigGate { + attempt += 1 + if (attempt == 1) throw java.io.IOException("down") else UiConfig(allowAutoMode = true) + } + + assertFalse(gate.allowsAutoMode(HOST_A)) // fail closed + assertTrue(gate.allowsAutoMode(HOST_A)) // recovered → now allowed + assertEquals(2, attempt) + } + + @Test + fun `cancellation propagates instead of being read as a policy answer`() = runTest { + val gate = UiConfigGate { throw CancellationException("scope cancelled") } + assertThrows(CancellationException::class.java) { + kotlinx.coroutines.runBlocking { gate.allowsAutoMode(HOST_A) } + } + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt index 6230355..67551d8 100644 --- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt @@ -43,6 +43,17 @@ public class DeviceEnroller( // AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no // process restart. Optional so the JVM orchestration tests can construct the enroller without it. private val cacheRefresher: IdentityCacheRefresher? = null, + /** + * A SECOND client over a PLAIN (non-mTLS) transport, used ONLY by [recover]. It must not be the + * same client as [client]: an expired leaf cannot authenticate its own re-issuance, and an mTLS + * transport would present that expired leaf, which nginx refuses to forward at all (bare 400). + * Null ⇒ recovery is unavailable and [recover] throws rather than falling back to mTLS. + * + * It MUST target the same control plane as [client] — the enrollment record stores [client]'s + * base URL as the rotation target, so a recovery pointed elsewhere would rewrite the identity + * while leaving the record naming the original host. + */ + private val recoveryClient: DeviceEnrollmentClient? = null, ) { private val commitMutex = Mutex() @@ -97,6 +108,40 @@ public class DeviceEnroller( summaryOf(result) } + /** + * Recover an EXPIRED leaf: re-CSR from the SAME hardware key and POST it together with the lapsed + * certificate to `POST /device/:id/recover` over the PLAIN (non-mTLS) [recoveryClient]. + * + * This exists because `/device/:id/renew` is authenticated by the very certificate it renews, so a + * lapsed leaf deadlocks: no TLS terminator will even forward an expired client certificate (nginx + * answers a bare 400 under `ssl_verify_client optional`, and `optional_no_ca` tolerates only CHAIN + * errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). Without this call an expired device certificate + * means a full manual re-enroll. Possession is still proven — the CSR is self-signed by the same + * non-exportable key and the control plane enforces CSR proof-of-possession plus + * `CSR key == registered key` — and revocation, account/`:id` consistency and `notBefore` are all + * still enforced server-side; only `notAfter` is graced (30 days). + * + * Failure posture: everything before [commitIdentity]'s cert-store save is read-only, so a rejected + * or unreachable recovery leaves the prior identity and its key exactly as they were. + * + * Throws [EnrollmentStateException] when there is no [recoveryClient], no enrollment record, no + * stored certificate to present, or no hardware key — none of which a request could fix. + */ + public suspend fun recover(): CertificateSummary = commitMutex.withLock { + val recovery = recoveryClient + ?: throw EnrollmentStateException("no plain-HTTPS recovery client — refusing to recover over mTLS") + val record = recordStore.load() + ?: throw EnrollmentStateException("no enrollment record — nothing to recover") + val expiredLeaf = certStore.load()?.leafCertificate + ?: throw EnrollmentStateException("no stored device certificate — a fresh enroll is required") + val key = keyProvider.load(record.keyStoreAlias) + ?: throw EnrollmentStateException("device key missing — a fresh enroll is required") + val csr = CertificateSigningRequest.der(record.deviceName, key) + val result = recovery.recover(record.deviceId, expiredLeaf.encoded, csr) + commitIdentity(result, record.deviceName, record.keyStoreAlias) + summaryOf(result) + } + /** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */ public suspend fun remove(): Unit = commitMutex.withLock { certStore.clear() @@ -120,7 +165,13 @@ public class DeviceEnroller( deviceId = result.deviceId, deviceName = deviceName, keyStoreAlias = alias, + // Only an enroll 201 carries renewAfter; a renew/recover 201 does not, so this is 0 + // after a rotation. That is fine — DeviceRotationStore derives the live timing from + // the leaf certificate, never from this advisory field. renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L, + // Persist WHICH control plane issued this identity so silent rotation (which has no UI + // to ask on) renews against the same one instead of a compiled-in default. + controlPlaneUrl = client.baseUrl, ), ) certStore.save( diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStore.kt new file mode 100644 index 0000000..d07e424 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStore.kt @@ -0,0 +1,60 @@ +package wang.yaojia.webterm.tlsandroid + +import wang.yaojia.webterm.api.enroll.DeviceRenewalState +import wang.yaojia.webterm.api.enroll.RotationPolicy + +/** + * Everything a rotation pass needs to know, read from storage: the pure [renewalState] the decision + * table consumes plus [controlPlaneUrl], the host to rotate against. + * + * Carries no credential: only the non-secret device id, the leaf's timing, and a URL. Safe to log. + */ +public data class DeviceRotationSnapshot( + val renewalState: DeviceRenewalState, + val controlPlaneUrl: String, +) + +/** + * The READ side of silent certificate rotation — the Android counterpart of iOS + * `KeychainClientIdentityStore.renewalState()`, composing the two persisted stores into one snapshot. + * + * ### Why the timing comes from the LEAF, not the record + * `POST /device/:id/renew` and `POST /device/:id/recover` answer `{cert, caChain, notAfter}` — neither + * returns a `renewAfter` (only `/device/enroll` does). A driver that trusted + * [EnrollmentRecord.renewAfterEpochSeconds] would therefore see "unknown" after the very first + * rotation and report not-due until the certificate died — exactly once-and-never-again rotation. The + * live leaf's own `notBefore`/`notAfter` are always present and are what the TLS stack actually + * enforces, so they are the source of truth; [RotationPolicy.renewAfterFor] re-derives the advisory + * instant from them with the control plane's own 2/3-of-lifetime fraction. + * + * Deliberately I/O-only and framework-free (no `android.*` import), so the whole read path is JVM + * unit-tested. A store fault PROPAGATES rather than degrading to "nothing enrolled": silently + * reporting not-enrolled would disable rotation forever, whereas a surfaced failure is retried. + */ +public class DeviceRotationStore( + private val certStore: CertStore, + private val recordStore: EnrollmentRecordStore, +) { + /** + * The current rotation snapshot, or null when there is nothing to rotate — no enrollment record + * (fresh install / legacy `.p12`-only identity) or no live certificate (a renew has nothing to + * re-CSR for and a recovery has no lapsed leaf to present, so only a fresh enroll can help). + * + * Performs AndroidKeyStore/Tink-backed reads via the injected stores: call it OFF the main thread. + */ + public fun read(): DeviceRotationSnapshot? { + val record = recordStore.load() ?: return null + val leaf = certStore.load()?.leafCertificate ?: return null + return DeviceRotationSnapshot( + renewalState = DeviceRenewalState( + deviceId = record.deviceId, + notAfter = leaf.notAfter.toInstant(), + renewAfter = RotationPolicy.renewAfterFor( + notBefore = leaf.notBefore.toInstant(), + notAfter = leaf.notAfter.toInstant(), + ), + ), + controlPlaneUrl = record.controlPlaneUrl, + ) + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt index 0926361..119ab96 100644 --- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt @@ -8,12 +8,23 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream +import java.io.EOFException /** * B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted * [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR * subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign - * with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler. + * with, [renewAfterEpochSeconds] (0 = unknown) as issued at enroll time, and [controlPlaneUrl] — + * WHICH control plane to rotate against. + * + * [controlPlaneUrl] exists because rotation is silent: there is no screen to re-type the URL on, and + * falling back to a compiled-in default would renew against the wrong host. It is not a credential + * (iOS keeps the same value in plain `UserDefaults`); a record written before the field existed + * decodes to `""`, which the rotation driver treats as "cannot rotate" rather than guessing. + * + * [renewAfterEpochSeconds] is only ever ADVISORY and only ever set by an enroll: neither + * `/device/:id/renew` nor `/device/:id/recover` returns a `renewAfter`. The live timing therefore + * comes from the leaf certificate itself — see [DeviceRotationStore]. * * This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert * identity is what the handshake presents; this record only exists so renew can find the device and @@ -24,6 +35,8 @@ public data class EnrollmentRecord( val deviceName: String, val keyStoreAlias: String, val renewAfterEpochSeconds: Long, + /** Control-plane base URL this device enrolled against; `""` when unknown (a legacy record). */ + val controlPlaneUrl: String = "", ) { init { require(deviceId.isNotBlank()) { "deviceId must not be blank" } @@ -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 fun encode(record: EnrollmentRecord): ByteArray { val out = ByteArrayOutputStream() @@ -40,6 +53,7 @@ public object EnrollmentRecordCodec { data.writeUTF(record.deviceName) data.writeUTF(record.keyStoreAlias) data.writeLong(record.renewAfterEpochSeconds) + data.writeUTF(record.controlPlaneUrl) } return out.toByteArray() } @@ -53,11 +67,25 @@ public object EnrollmentRecordCodec { deviceName = data.readUTF(), keyStoreAlias = data.readUTF(), renewAfterEpochSeconds = data.readLong(), + controlPlaneUrl = readTrailingUtfOrBlank(data), ) } } catch (e: Exception) { throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e) } + + /** + * Read the trailing [EnrollmentRecord.controlPlaneUrl], tolerating its ABSENCE (a blob written + * before the field existed) as `""`. Only end-of-blob is tolerated: a record that ends exactly + * after the long is a valid older layout, whereas a mangled one still fails the reads above and is + * reported corrupt. Declaring an existing enrollment corrupt would strand a working device. + */ + private fun readTrailingUtfOrBlank(data: DataInputStream): String = + try { + data.readUTF() + } catch (_: EOFException) { + "" + } } /** diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt index f999228..e3134ed 100644 --- a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt @@ -5,15 +5,13 @@ import okhttp3.OkHttpClient import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError import wang.yaojia.webterm.testsupport.FakeHttpTransport import wang.yaojia.webterm.wire.HttpMethod -import java.security.KeyPairGenerator -import java.security.interfaces.ECPublicKey -import java.security.spec.ECGenParameterSpec /** * B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs @@ -30,53 +28,32 @@ class DeviceEnrollerTest { const val BASE = "https://cp.terminal.yaojia.wang" const val ALIAS = "test-device-key" - // Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory / - // CertificateSummaryReader parse them exactly as they parse a server-issued leaf. - const val LEAF_CN = "t1-device" - const val CA_CN = "webterm-device-ca" - const val LEAF_B64 = - "MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" + - "ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" + - "WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" + - "cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" + - "HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" + - "ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" + - "cdoleWDlqcMKU" - const val CA_B64 = - "MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" + - "dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" + - "YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" + - "e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" + - "4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" + - "Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" + - "YloHuEAg+kngzA33m52aWtublai4L+eybg==" + // Real self-signed P-256 X.509 certs + a software key double, shared with the rotation tests. + const val LEAF_CN = RotationTestFixtures.LEAF_CN + const val CA_CN = RotationTestFixtures.CA_CN - fun loginBody(): ByteArray = - """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray() + fun loginBody(): ByteArray = RotationTestFixtures.loginBody() - fun enrollBody(deviceId: String = "dev-1"): ByteArray = - """ - {"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"], - "notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z", - "renewAfter":"2026-09-05T00:00:00.000Z"} - """.trimIndent().toByteArray() + fun enrollBody(deviceId: String = "dev-1"): ByteArray = RotationTestFixtures.enrollBody(deviceId) - fun softwareKey(alias: String): HardwareBackedKey { - val kpg = KeyPairGenerator.getInstance("EC") - kpg.initialize(ECGenParameterSpec("secp256r1")) - val kp = kpg.generateKeyPair() - return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey) - } + fun softwareKey(alias: String): HardwareBackedKey = RotationTestFixtures.softwareKey(alias) } private val events = mutableListOf() private val transport = FakeHttpTransport() + + /** + * The recovery transport is a SEPARATE double on purpose: `/device/:id/recover` must ride a PLAIN + * (non-mTLS) client, because no TLS terminator forwards an expired client certificate. Using two + * transports is what lets these tests prove the routing, not just the request shape. + */ + private val recoveryTransport = FakeHttpTransport() private val certStore = RecordingCertStore(events) private val recordStore = RecordingRecordStore(events) private val keyProvider = RecordingKeyProvider(events) private val refresher = RecordingRefresher(events) - private fun enroller(): DeviceEnroller = + private fun enroller(withRecovery: Boolean = true): DeviceEnroller = DeviceEnroller( client = DeviceEnrollmentClient(BASE, transport), certStore = certStore, @@ -85,6 +62,7 @@ class DeviceEnrollerTest { keyAlias = ALIAS, keyProvider = keyProvider, cacheRefresher = refresher, + recoveryClient = if (withRecovery) DeviceEnrollmentClient(BASE, recoveryTransport) else null, ) // ── enroll: commit sequencing (the security-critical invariant) ──────────────────────────── @@ -221,6 +199,166 @@ class DeviceEnrollerTest { assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit") } + @Test + fun enrollPersistsTheControlPlaneUrlSoRotationKnowsWhereToRenew() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody()) + + enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel") + + // Silent rotation happens with no UI, so the control plane the device enrolled against must be + // persisted — a rotation that fell back to a compiled-in default would renew somewhere else. + assertEquals(BASE, recordStore.saved!!.controlPlaneUrl) + } + + @Test + fun renewDecodesTheRealReissueResponseThatCarriesNoDeviceId() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + // The real /device/:id/renew 201 body — {cert, caChain, notAfter} with NO deviceId. + transport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = RotationTestFixtures.reissueBody(), + ) + + val summary = enroller().renew() + + assertEquals(LEAF_CN, summary.subjectCommonName) + assertEquals("dev-1", recordStore.saved!!.deviceId, "the device id survives the re-issue") + assertEquals(BASE, recordStore.saved!!.controlPlaneUrl) + } + + // ── recover: the EXPIRED-leaf escape hatch (plain HTTPS, cert in the body) ────────────────── + + @Test + fun recoverPostsTheStoredLeafAndAFreshCsrOnThePlainTransportNotTheMtlsOne() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(), + ) + + val summary = enroller().recover() + + // The request went out on the PLAIN transport. If it had gone on the mTLS one it would have + // presented the expired leaf and nginx would have answered a bare 400 — the production deadlock. + assertTrue(transport.recordedRequests.isEmpty(), "recovery must NEVER ride the mTLS transport") + val request = recoveryTransport.recordedRequests.single() + assertEquals("$BASE/device/dev-1/recover", request.url) + assertNull(request.headers["Authorization"], "recovery carries no bearer") + val body = request.body!!.decodeToString() + assertTrue(body.contains("\"cert\":"), "the lapsed leaf travels in the BODY") + assertTrue(body.contains(RotationTestFixtures.LEAF_B64.take(32)), "the body carries the stored leaf") + assertTrue(body.contains("\"csr\":"), "possession is proven by a fresh CSR over the same key") + assertEquals(LEAF_CN, summary.subjectCommonName) + } + + @Test + fun recoverCommitsWithTheSameSequencingAsEnrollAndRenew() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(), + ) + + enroller().recover() + + assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"), "record before the pointer flip") + assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh after the commit") + } + + @Test + fun recoverReusesTheSameHardwareKeyAndNeverGeneratesANewOne() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(), + ) + + enroller().recover() + + // The server enforces `CSR key == registered key`; generating a key here would guarantee a 403. + assertTrue(keyProvider.generatedAliases.isEmpty(), "recovery re-CSRs from the EXISTING key") + assertTrue(keyProvider.deletedAliases.isEmpty(), "a successful recovery deletes nothing") + } + + @Test + fun recoverThrowsWhenNoPlainRecoveryClientIsConfigured() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + + val error = runCatching { enroller(withRecovery = false).recover() }.exceptionOrNull() + + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(recoveryTransport.recordedRequests.isEmpty()) + assertTrue(transport.recordedRequests.isEmpty(), "and it must not silently fall back to mTLS") + } + + @Test + fun recoverThrowsWhenThereIsNoStoredLeafToPresent() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + // No cert in the store → nothing to put in the body → a fresh enroll is the only way out. + val error = runCatching { enroller().recover() }.exceptionOrNull() + + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(recoveryTransport.recordedRequests.isEmpty(), "no pointless round-trip") + } + + @Test + fun recoverThrowsWhenNothingIsEnrolled() = runTest { + val error = runCatching { enroller().recover() }.exceptionOrNull() + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(recoveryTransport.recordedRequests.isEmpty()) + } + + @Test + fun aFailedRecoveryLeavesThePriorIdentityUntouched() = runTest { + val prior = storedIdentity() + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(prior) + // Beyond the 30-day grace the control plane answers a uniform 401. + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 401, body = """{"error":"rejected"}""".toByteArray(), + ) + + val error = runCatching { enroller().recover() }.exceptionOrNull() + + assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error) + assertSame(prior, certStore.load(), "a failed recovery must not touch the live cert pointer") + assertFalse(events.contains("cert.save"), "no pointer flip on failure") + assertFalse(events.contains("cache.refresh"), "and nothing is published to the in-memory cache") + assertTrue(keyProvider.deletedAliases.isEmpty(), "the key that still works is NEVER deleted") + } + + @Test + fun theRecoveryFailureNeverCarriesTheCertificateBytes() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 403, body = """{"error":"rejected"}""".toByteArray(), + ) + + val error = runCatching { enroller().recover() }.exceptionOrNull()!! + + val rendered = "${error.message} $error" + assertFalse(rendered.contains(RotationTestFixtures.LEAF_B64.take(32)), "no cert bytes in the error") + assertFalse(rendered.contains("MII"), "no DER/PEM material in a loggable rendering") + } + + private fun storedIdentity(): StoredIdentityMetadata = + StoredIdentityMetadata( + alias = ALIAS, + keyAlgorithm = "EC", + keyStoreAlias = ALIAS, + certificateChain = listOf(RotationTestFixtures.leafCertificate(), RotationTestFixtures.caCertificate()), + ) + // ── remove: full teardown ────────────────────────────────────────────────────────────────── @Test @@ -240,17 +378,25 @@ class DeviceEnrollerTest { private class RecordingCertStore(private val events: MutableList) : CertStore { var saved: StoredIdentityMetadata? = null var cleared = false + private var current: StoredIdentityMetadata? = null + + /** Pre-existing on-disk identity (the lapsed leaf the recovery path must present). */ + fun seed(metadata: StoredIdentityMetadata) { + current = metadata + } override fun save(metadata: StoredIdentityMetadata) { saved = metadata + current = metadata events += "cert.save" } - override fun load(): StoredIdentityMetadata? = saved + override fun load(): StoredIdentityMetadata? = current override fun clear() { cleared = true saved = null + current = null events += "cert.clear" } } diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStoreTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStoreTest.kt new file mode 100644 index 0000000..777cf0a --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStoreTest.kt @@ -0,0 +1,166 @@ +package wang.yaojia.webterm.tlsandroid + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.IOException +import java.time.Duration +import java.time.Instant + +/** + * [DeviceRotationStore] is the read side of silent rotation: it composes the two persisted stores into + * the timing snapshot the pure `RotationPolicy` consumes. The load-bearing behaviour is that the + * timing comes from the LIVE LEAF CERTIFICATE, not from the enrollment record — a record written by a + * renew/recover commit has no `renewAfter` at all (the server does not return one), so a store that + * trusted the record would rotate once and then report "not due" until the cert died. + */ +class DeviceRotationStoreTest { + private companion object { + const val ALIAS = "test-device-key" + const val CP_URL = "https://cp.terminal.yaojia.wang" + } + + private val certStore = FakeCertStore() + private val recordStore = FakeRecordStore() + private val store = DeviceRotationStore(certStore, recordStore) + + /** JUnit5's `assertNotNull` returns Unit, so assert-and-unwrap in one place. */ + private fun readSnapshot(): DeviceRotationSnapshot { + val snapshot = store.read() + assertNotNull(snapshot, "expected an enrolled rotation snapshot") + return snapshot!! + } + + private fun seedEnrolled( + controlPlaneUrl: String = CP_URL, + renewAfterEpochSeconds: Long = 0L, + ) { + recordStore.current = EnrollmentRecord( + deviceId = "dev-1", + deviceName = "Alice Pixel", + keyStoreAlias = ALIAS, + renewAfterEpochSeconds = renewAfterEpochSeconds, + controlPlaneUrl = controlPlaneUrl, + ) + certStore.current = StoredIdentityMetadata( + alias = ALIAS, + keyAlgorithm = "EC", + keyStoreAlias = ALIAS, + certificateChain = listOf(RotationTestFixtures.leafCertificate(), RotationTestFixtures.caCertificate()), + ) + } + + @Test + fun readReturnsNullWhenNothingIsEnrolled() { + assertNull(store.read(), "a fresh install has nothing to rotate") + } + + @Test + fun readReturnsNullWhenTheCertLivePointerIsAbsent() { + recordStore.current = EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, CP_URL) + // A record with no live cert cannot be renewed OR recovered (recovery needs the lapsed leaf to + // put in the body), so this is "nothing to rotate", not a failure. + assertNull(store.read()) + } + + @Test + fun readTakesTheExpiryFromTheLeafCertificate() { + seedEnrolled() + val snapshot = readSnapshot() + assertEquals("dev-1", snapshot.renewalState.deviceId) + assertEquals( + Instant.parse(RotationTestFixtures.LEAF_NOT_AFTER), + snapshot.renewalState.notAfter, + "notAfter is the leaf's own hard expiry — the value the TLS stack actually enforces", + ) + } + + @Test + fun readDerivesRenewAfterFromTheLeafAndIgnoresTheStaleRecordValue() { + // A record committed by a renew carries renewAfterEpochSeconds = 0 (unknown) because the + // renew 201 has no renewAfter; a record committed by an enroll carries a value that is stale + // the moment the leaf is replaced. Either way the LEAF is the source of truth. + seedEnrolled(renewAfterEpochSeconds = Instant.parse("1999-01-01T00:00:00Z").epochSecond) + val notBefore = Instant.parse(RotationTestFixtures.LEAF_NOT_BEFORE) + val notAfter = Instant.parse(RotationTestFixtures.LEAF_NOT_AFTER) + // The control plane's own formula: notBefore + 2/3 · lifetime. + val expected = notBefore.plus(Duration.between(notBefore, notAfter).multipliedBy(2).dividedBy(3)) + + val snapshot = readSnapshot() + + assertEquals(expected, snapshot.renewalState.renewAfter) + assertTrue(snapshot.renewalState.renewAfter!!.isAfter(notBefore)) + assertTrue(snapshot.renewalState.renewAfter!!.isBefore(notAfter)) + } + + @Test + fun readCarriesTheControlPlaneUrlSoRotationTargetsTheRightHost() { + seedEnrolled(controlPlaneUrl = "https://cp.example.test") + assertEquals("https://cp.example.test", readSnapshot().controlPlaneUrl) + } + + @Test + fun readReportsABlankControlPlaneUrlRatherThanGuessingOne() { + // A record written before the URL was persisted decodes to "". Renewing against a DEFAULT host + // would silently talk to the wrong control plane, so the blank is surfaced verbatim and the + // caller must refuse to rotate. + seedEnrolled(controlPlaneUrl = "") + assertEquals("", readSnapshot().controlPlaneUrl) + } + + @Test + fun readPropagatesAStoreFaultInsteadOfReportingNotEnrolled() { + seedEnrolled() + certStore.fault = IOException("tink blob unreadable") + // Degrading a decrypt fault to "nothing enrolled" would silently disable rotation forever; the + // caller surfaces it as a failure instead. + assertTrue(runCatching { store.read() }.exceptionOrNull() is IOException) + } + + @Test + fun theSnapshotCarriesNoCredentialInItsToString() { + seedEnrolled() + val rendered = readSnapshot().toString() + assertTrue(rendered.contains("dev-1")) + assertTrue(rendered.contains("cp.terminal.yaojia.wang")) + assertFalse(rendered.contains("MIIB"), "no certificate DER may reach a loggable rendering") + assertFalse(rendered.contains(RotationTestFixtures.LEAF_B64), "no leaf bytes in the snapshot") + } + + // ── doubles ──────────────────────────────────────────────────────────────────────────────── + + private class FakeCertStore : CertStore { + var current: StoredIdentityMetadata? = null + var fault: Exception? = null + + override fun save(metadata: StoredIdentityMetadata) { + current = metadata + } + + override fun load(): StoredIdentityMetadata? { + fault?.let { throw it } + return current + } + + override fun clear() { + current = null + } + } + + private class FakeRecordStore : EnrollmentRecordStore { + var current: EnrollmentRecord? = null + + override fun save(record: EnrollmentRecord) { + current = record + } + + override fun load(): EnrollmentRecord? = current + + override fun clear() { + current = null + } + } +} diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordCodecTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordCodecTest.kt new file mode 100644 index 0000000..12903d6 --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordCodecTest.kt @@ -0,0 +1,53 @@ +package wang.yaojia.webterm.tlsandroid + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream + +/** + * [EnrollmentRecordCodec] round-trip, plus the one compatibility rule that matters: a blob written + * BEFORE `controlPlaneUrl` existed must still decode (to a blank URL) rather than being reported as + * corrupt — a corrupt-blob verdict on an existing enrollment would strand a working device. + */ +class EnrollmentRecordCodecTest { + + @Test + fun roundTripsEveryField() { + val record = EnrollmentRecord( + deviceId = "dev-1", + deviceName = "Alice Pixel", + keyStoreAlias = "webterm-device-key", + renewAfterEpochSeconds = 1_790_000_000L, + controlPlaneUrl = "https://cp.terminal.yaojia.wang", + ) + assertEquals(record, EnrollmentRecordCodec.decode(EnrollmentRecordCodec.encode(record))) + } + + @Test + fun aBlobWrittenBeforeTheControlPlaneUrlFieldDecodesToABlankUrl() { + // Exactly the four-field layout the previous codec version emitted. + val legacy = ByteArrayOutputStream().also { out -> + DataOutputStream(out).use { data -> + data.writeUTF("dev-1") + data.writeUTF("Alice Pixel") + data.writeUTF("webterm-device-key") + data.writeLong(0L) + } + }.toByteArray() + + val decoded = EnrollmentRecordCodec.decode(legacy) + + assertEquals("dev-1", decoded.deviceId) + assertEquals("", decoded.controlPlaneUrl, "an absent trailing field is unknown, not corrupt") + } + + @Test + fun aTruncatedBlobIsStillReportedAsCorrupt() { + // Tolerating the ABSENT trailing field must not turn into tolerating a mangled record. + assertThrows(CorruptStoredIdentityException::class.java) { + EnrollmentRecordCodec.decode(byteArrayOf(0x00, 0x05, 0x64)) + } + } +} diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/RotationTestFixtures.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/RotationTestFixtures.kt new file mode 100644 index 0000000..7d7d36d --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/RotationTestFixtures.kt @@ -0,0 +1,78 @@ +package wang.yaojia.webterm.tlsandroid + +import java.security.KeyPairGenerator +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.util.Base64 + +/** + * Shared JVM test fixtures for the enroll / rotate / recover orchestration: REAL self-signed P-256 + * X.509 certificates (base64 DER) so `CertificateFactory`, `CertificateSummaryReader` and the + * rotation-timing reader parse them exactly as they parse a server-issued leaf — and a software P-256 + * key double standing in for the non-exportable AndroidKeyStore key (which needs a device). + */ +internal object RotationTestFixtures { + const val LEAF_CN: String = "t1-device" + const val CA_CN: String = "webterm-device-ca" + + /** Validity window baked into [LEAF_B64] (read back from the DER, asserted in the store tests). */ + const val LEAF_NOT_BEFORE: String = "2026-07-18T11:21:11Z" + const val LEAF_NOT_AFTER: String = "2126-06-24T11:21:11Z" + + const val LEAF_B64: String = + "MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" + + "ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" + + "WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" + + "cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" + + "HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" + + "ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" + + "cdoleWDlqcMKU" + + const val CA_B64: String = + "MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" + + "dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" + + "YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" + + "e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" + + "4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" + + "Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" + + "YloHuEAg+kngzA33m52aWtublai4L+eybg==" + + fun leafDer(): ByteArray = Base64.getDecoder().decode(LEAF_B64) + + fun leafCertificate(): X509Certificate = parse(leafDer()) + + fun caCertificate(): X509Certificate = parse(Base64.getDecoder().decode(CA_B64)) + + /** The enroll 201 body — the ONLY route that returns deviceId/notBefore/renewAfter. */ + fun enrollBody(deviceId: String = "dev-1"): ByteArray = + """ + {"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"], + "notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z", + "renewAfter":"2026-09-05T00:00:00.000Z"} + """.trimIndent().toByteArray() + + /** + * The REAL `/device/:id/renew` and `/device/:id/recover` 201 body: `{cert, caChain, notAfter}` — + * no deviceId, no notBefore, no renewAfter (control-plane/src/api/renew.ts). + */ + fun reissueBody(): ByteArray = + """ + {"cert":"$LEAF_B64","caChain":["$CA_B64"],"notAfter":"2126-06-24T11:21:11.000Z"} + """.trimIndent().toByteArray() + + fun loginBody(): ByteArray = + """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray() + + /** A software P-256 key standing in for the non-exportable AndroidKeyStore key (§7: no emulator). */ + fun softwareKey(alias: String): HardwareBackedKey { + val generator = KeyPairGenerator.getInstance("EC") + generator.initialize(ECGenParameterSpec("secp256r1")) + val pair = generator.generateKeyPair() + return HardwareBackedKey(alias, pair.private, pair.public as ECPublicKey) + } + + private fun parse(der: ByteArray): X509Certificate = + CertificateFactory.getInstance("X.509").generateCertificate(der.inputStream()) as X509Certificate +} diff --git a/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStoreTest.kt b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStoreTest.kt new file mode 100644 index 0000000..f6822a3 --- /dev/null +++ b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStoreTest.kt @@ -0,0 +1,188 @@ +package wang.yaojia.webterm.hostregistry + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID + +/** + * Instrumented (device) contract tests for [DataStoreSessionWatermarkStore] — the storage half + * of the module, which needs a real Context/file (plan §7). + * + * The load-bearing one is [survives_a_new_store_instance_over_the_same_file]: that is the + * process-death simulation this whole class exists for. The rest mirror the JVM transform tests + * at the storage boundary (garbage at rest is dropped, the cap holds, an emptied map removes the + * key instead of leaving `{}` behind). + */ +@RunWith(AndroidJUnit4::class) +class DataStoreSessionWatermarkStoreTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + /** + * A fresh, uniquely-named Preferences file per test (no cross-test bleed). [scope] is + * injectable because DataStore throws if two instances are live on ONE file: releasing the + * file means cancelling the owning scope, which is how process death is simulated below. + */ + private fun newDataStore( + name: String = "watermarks-test-${UUID.randomUUID()}", + scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + ): DataStore { + val ctx = ApplicationProvider.getApplicationContext() + return PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { ctx.preferencesDataStoreFile(name) }, + ) + } + + @Test + fun an_empty_store_loads_an_empty_map() = runBlocking { + assertTrue(DataStoreSessionWatermarkStore(newDataStore()).loadAll().isEmpty()) + } + + @Test + fun replaceAll_then_loadAll_returns_the_same_watermarks() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + + store.replaceAll(mapOf(idA to 10L, idB to 20L)) + + assertEquals(mapOf(idA to 10L, idB to 20L), store.loadAll()) + } + + @Test + fun survives_a_new_store_instance_over_the_same_file() = runBlocking { + val fileName = "watermarks-persist-${UUID.randomUUID()}" + val firstScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // replaceAll suspends until edit() has committed to disk. + DataStoreSessionWatermarkStore(newDataStore(fileName, firstScope)) + .replaceAll(mapOf(idA to 1_700_000_000_000L)) + + // Cancelling the owning scope releases the file — DataStore refuses two live instances + // on one file, and letting go of it is exactly what process death does. + firstScope.coroutineContext.job.cancelAndJoin() + + // A brand-new store over the same file = what a cold start after process death sees. + val reopened = DataStoreSessionWatermarkStore(newDataStore(fileName)) + + assertEquals(mapOf(idA to 1_700_000_000_000L), reopened.loadAll()) + } + + @Test + fun replaceAll_is_a_wholesale_swap_not_a_merge() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + store.replaceAll(mapOf(idA to 10L)) + + store.replaceAll(mapOf(idB to 20L)) + + assertEquals(mapOf(idB to 20L), store.loadAll()) + } + + @Test + fun remove_drops_one_session_and_keeps_the_rest() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + store.replaceAll(mapOf(idA to 10L, idB to 20L)) + + val remaining = store.remove(idA) + + assertEquals(mapOf(idB to 20L), remaining) + assertEquals(remaining, store.loadAll()) + } + + @Test + fun removing_an_unknown_session_is_a_no_op() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + store.replaceAll(mapOf(idA to 10L)) + + assertEquals(mapOf(idA to 10L), store.remove(idB)) + } + + @Test + fun emptying_the_map_removes_the_stored_key_instead_of_writing_an_empty_blob() = runBlocking { + val dataStore = newDataStore() + val store = DataStoreSessionWatermarkStore(dataStore) + store.replaceAll(mapOf(idA to 10L)) + + store.remove(idA) + + assertNull(dataStore.data.first()[stringPreferencesKey(WATERMARKS_KEY_NAME)]) + } + + @Test + fun garbage_stored_at_rest_reads_back_as_no_watermarks() = runBlocking { + val dataStore = newDataStore() + dataStore.edit { it[stringPreferencesKey(WATERMARKS_KEY_NAME)] = "{not json" } + + assertTrue(DataStoreSessionWatermarkStore(dataStore).loadAll().isEmpty()) + } + + @Test + fun a_non_v4_id_stored_at_rest_is_dropped_on_read() = runBlocking { + val dataStore = newDataStore() + // A v1 UUID: parses as a UUID (the DataStoreLastSessionStore precedent bug) but is not + // a valid wire session id, so Validation.isValidSessionId must reject it. + val v1 = "3f2504e0-4f89-11d3-9a0c-0305e82c3301" + dataStore.edit { it[stringPreferencesKey(WATERMARKS_KEY_NAME)] = """{"$idA":10,"$v1":20}""" } + + assertEquals(mapOf(idA to 10L), DataStoreSessionWatermarkStore(dataStore).loadAll()) + } + + @Test + fun the_stored_map_is_capped_so_it_cannot_grow_without_bound() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + val oversized = (0 until MAX_WATERMARKS + 5).associate { sessionId(it) to (it + 1).toLong() } + + val stored = store.replaceAll(oversized) + + assertEquals(MAX_WATERMARKS, stored.size) + assertEquals(MAX_WATERMARKS, store.loadAll().size) + assertFalse(store.loadAll().containsKey(sessionId(0))) // oldest-seen dropped first + } + + @Test + fun the_watermark_key_is_disjoint_from_the_other_stores_over_one_file() = runBlocking { + // ONE Preferences DataStore is shared app-wide (:app StorageModule) — this is the + // regression test for "never collide with a sibling store's key". + val dataStore = newDataStore() + val hosts = DataStoreHostStore(dataStore) + val lastSession = DataStoreLastSessionStore(dataStore) + val watermarks = DataStoreSessionWatermarkStore(dataStore) + val host = Host.create(id = "host-1", name = "mac", baseUrl = "http://10.0.2.2:3000")!! + + hosts.upsert(host) + lastSession.setLastSessionId(idB, hostId = "host-1") + watermarks.replaceAll(mapOf(idA to 10L)) + + assertEquals(listOf(host), hosts.loadAll()) + assertEquals(idB, lastSession.lastSessionId("host-1")) + assertEquals(mapOf(idA to 10L), watermarks.loadAll()) + } + + /** A distinct valid v4 id per [index] (the last 4 hex digits carry the index). */ + private fun sessionId(index: Int): String = + "3f2504e0-4f89-41d3-9a0c-0305e82c%04x".format(index) + + private companion object { + /** Must match `DataStoreSessionWatermarkStore.WATERMARKS_KEY` (private there, asserted here). */ + const val WATERMARKS_KEY_NAME = "unreadWatermarks" + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStore.kt new file mode 100644 index 0000000..92b94a9 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStore.kt @@ -0,0 +1,65 @@ +package wang.yaojia.webterm.hostregistry + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first + +/** + * Preferences-DataStore-backed [SessionWatermarkStore]. The whole watermark map is ONE JSON + * string under [WATERMARKS_KEY] ([SessionWatermarkCodec]) — the same shape [DataStoreHostStore] + * uses for `"hosts"`, and for the same reason: the cap and the sanitize pass have to apply to + * the map as a whole, which a per-session key prefix (the [DataStoreLastSessionStore] shape) + * cannot do without scanning every key on every write and still leaking orphaned keys. + * + * The key name mirrors the iOS `UserDefaults` key `"unreadWatermarks"`. It is **disjoint** from + * every other key over this file (`"hosts"`, `"lastSessionId."`, `"authCookiesSealed"`), + * because ONE Preferences DataStore instance is shared app-wide (`:app` `StorageModule`) — a + * second instance over the same file would corrupt it, so never open one. + * + * Reads decode then [forStorage] (validate + cap): the blob is untrusted at rest. Writes are an + * atomic read-modify-write via [DataStore.edit], and an empty result REMOVES the key rather than + * writing `{}`, so the file shrinks back to nothing. + * + * The [DataStore] is injected (constructed from a Context in `:app` DI) so this class has no + * Android-framework surface of its own; the on-device behaviour — including survival across a + * fresh store instance, i.e. process death — is verified instrumented (androidTest, plan §7). + * Not encrypted, deliberately: see the posture note on [SessionWatermarkStore]. + */ +public class DataStoreSessionWatermarkStore( + private val dataStore: DataStore, +) : SessionWatermarkStore { + + override suspend fun loadAll(): Map = + SessionWatermarkCodec.decode(dataStore.data.first()[WATERMARKS_KEY]).forStorage() + + override suspend fun replaceAll(watermarks: Map): Map = + writeTransform { watermarks.forStorage() } + + override suspend fun remove(sessionId: String): Map = + writeTransform { it.removingWatermark(sessionId) } + + private suspend fun writeTransform( + transform: (Map) -> Map, + ): Map { + lateinit var updated: Map + dataStore.edit { prefs -> + // Decoding through forStorage() means every write also garbage-collects invalid + // rows and re-applies the cap, even when the transform itself only removes. + val current = SessionWatermarkCodec.decode(prefs[WATERMARKS_KEY]).forStorage() + updated = transform(current) + if (updated.isEmpty()) { + prefs.remove(WATERMARKS_KEY) + } else { + prefs[WATERMARKS_KEY] = SessionWatermarkCodec.encode(updated) + } + } + return updated + } + + private companion object { + /** The one stored value: `SessionWatermarkCodec.encode(watermarks)`. */ + val WATERMARKS_KEY = stringPreferencesKey("unreadWatermarks") + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStore.kt new file mode 100644 index 0000000..1d18f6c --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStore.kt @@ -0,0 +1,40 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * In-memory [SessionWatermarkStore]. Lives in `main` (not `test`) on purpose — it stands in for + * the DataStore store in this module's contract tests AND in the AW4 ViewModel tests, exactly + * like [InMemoryHostStore] / [InMemoryAuthCookieStore]. + * + * It applies the SAME [forStorage] rules (validation + cap) as [DataStoreSessionWatermarkStore]; + * a laxer double would let a ViewModel test pass against behaviour no device exhibits. + * + * A [Mutex] serialises each read-modify-write; the state is a value snapshot replaced wholesale + * on every change (no in-place mutation of a shared reference). + */ +public class InMemorySessionWatermarkStore( + initial: Map = emptyMap(), +) : SessionWatermarkStore { + private val mutex = Mutex() + + // forStorage() always builds a fresh map, so a mutable map handed to the ctor cannot alias state. + private var stored: Map = initial.forStorage() + + override suspend fun loadAll(): Map = mutex.withLock { stored } + + override suspend fun replaceAll(watermarks: Map): Map = + write { watermarks.forStorage() } + + override suspend fun remove(sessionId: String): Map = + write { it.removingWatermark(sessionId) } + + private suspend fun write( + transform: (Map) -> Map, + ): Map = mutex.withLock { + val updated = transform(stored) + stored = updated + updated + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodec.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodec.kt new file mode 100644 index 0000000..4321655 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodec.kt @@ -0,0 +1,33 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Pure JSON codec for the persisted watermark map (shared by [DataStoreSessionWatermarkStore], + * JVM-testable without a Context). Mirrors [HostCodec]: encode is total, decode is defensive — + * a corrupt/undecodable blob reads back as "no watermarks" so a cold start can never crash on + * a bad row. + * + * Deliberately dumb about *meaning*: it does not validate session ids or instants. That is + * [sanitizedWatermarks]' job, so exactly one place owns the trust decision (and the in-memory + * double gets the identical treatment without touching JSON). + */ +internal object SessionWatermarkCodec { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun encode(watermarks: Map): String = json.encodeToString(watermarks) + + fun decode(raw: String?): Map { + if (raw.isNullOrBlank()) return emptyMap() + return try { + json.decodeFromString>(raw) + } catch (_: Exception) { + emptyMap() // corrupted blob at rest → start clean, never crash + } + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkStore.kt new file mode 100644 index 0000000..2e429fa --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkStore.kt @@ -0,0 +1,151 @@ +package wang.yaojia.webterm.hostregistry + +import wang.yaojia.webterm.wire.Validation + +/** + * Durable storage for the unread **watermarks** — `sessionId → last-seen instant (ms since + * epoch)` — that decide the session-list unread dot. Ports the iOS + * `UnreadWatermarkStore` / `UserDefaultsUnreadWatermarkStore` pair (plan §2 lists "unread + * watermarks" under *Persistence (non-secret)*). + * + * Implementations: [DataStoreSessionWatermarkStore] (real, Preferences-DataStore-backed) and + * [InMemorySessionWatermarkStore] (in-`main` double, exactly like [InMemoryHostStore] / + * [InMemoryAuthCookieStore]). + * + * ## Why this exists at all + * Without it the dot resets whenever the process dies — which is precisely when it matters: + * the product premise is walking away and coming back, and a dot that only survives while + * the app is warm answers a question the user never asked. + * + * ## Division of labour with `UnreadLedger` (do not duplicate it) + * The *reducer* — monotonic `record`, the `isUnread` comparison, the tie-broken cap — lives + * in `:session-core`'s `UnreadLedger`, which is deliberately persistence-agnostic. This + * module cannot see it (`:host-registry` depends only on `:wire-protocol`; nothing points + * sideways — see `android/README.md`), and must not restate it. So the contract here is + * plain-map I/O, like the iOS store's `[UUID: Int]`: `:app` owns the ledger, hands its + * `watermarks` map to [replaceAll], and rebuilds a ledger from [loadAll] on cold start. + * + * ## At-rest posture — no Tink here, deliberately + * The neighbouring [AuthCookieStore] blob IS Tink-AEAD-sealed, so a reader will reasonably + * ask why this one is not. Because it is not a credential and not private data: a watermark + * is a UUID the device already stores in the clear (the host list, the last-session id) plus + * a coarse timestamp of when the user last *looked* at a session. Sealing it would buy + * nothing an attacker with app-private read access does not already have, while adding a + * fail-closed dependency on `AndroidKeyStore` — i.e. a keystore hiccup would silently reset + * unread state. Plan §2's split is explicit: secrets get the Tink/AndroidKeyStore tier, + * non-secret UI state gets plain Preferences DataStore. This is the second tier, same as + * [HostStore] and [LastSessionStore]. (`android:allowBackup="false"` still applies to the + * whole file.) + * + * ## Untrusted at rest + * Persisted ids are re-validated on every read with the frozen + * [Validation.isValidSessionId] — the SAME v4-specific guard the wire codec applies to + * server-issued ids, **not** a format-only `UUID.fromString` (that mistake was already made + * once in [DataStoreLastSessionStore] and had to be corrected; do not repeat it). + * + * Immutable style throughout, like [HostStore]: mutations RETURN the new map instead of + * mutating shared state, an unknown id is an explicit no-op, and encounter order is + * preserved (only the cap may reorder — see [cappedWatermarks]). + */ +public interface SessionWatermarkStore { + /** + * Every stored watermark, already sanitized and capped ([forStorage]). Empty when + * nothing is stored or the blob no longer decodes — never throws. + */ + public suspend fun loadAll(): Map + + /** + * Swap the whole map for [watermarks] — the write `:app` drives after a `markSeen` + * ("this is the ledger now"). A wholesale swap, NOT a merge: the in-memory `UnreadLedger` + * is the source of truth, so a merge here could resurrect an entry the ledger dropped. + * Returns exactly what was stored (i.e. [watermarks].[forStorage]), which may be a + * subset of the argument. + */ + public suspend fun replaceAll(watermarks: Map): Map + + /** + * Forget [sessionId]'s watermark — the eager GC hook for a session that ended (`exit` + * frame / a kill), matching how [LastSessionStore] is cleared on `.exited`. An unknown + * or malformed id is an explicit no-op returning the unchanged map, never a throw. + */ + public suspend fun remove(sessionId: String): Map +} + +// ── Pure immutable transforms shared by every SessionWatermarkStore ─────────────────────── +// (DRY, mirrors HostStore.kt / AuthCookieStore.kt). Never mutate the receiver — always +// return a fresh map. `internal` so both stores and the same-module JVM tests can use them +// without widening the public surface. + +/** + * Hard ceiling on stored watermarks. **This is what bounds growth** (see the GC note below) + * and mirrors `UnreadLedger.MAX_ENTRIES` in `:session-core`, which cannot be imported here + * (no sideways module edge). Keep the two numbers equal: the ledger caps what `:app` holds, + * this caps what reaches the disk, and a store that trusted the caller would be no bound at + * all. + * + * ### GC rule (the decision, stated explicitly) + * Sessions are ephemeral, so a watermark can outlive its session forever. Growth is bounded + * by **three** rules, in order of preference: + * 1. **Eager** — [SessionWatermarkStore.remove] on a session that exits or is killed. This + * is the only rule that frees an entry the moment it is provably dead. + * 2. **Structural** — this count cap. Over [MAX_WATERMARKS], the OLDEST-seen entries are + * dropped ([cappedWatermarks]), so the file can never exceed ~512 · ~50 B ≈ 25 KB no + * matter how many sessions a device sees or how many exits it misses (force-stop, crash, + * a host killed from another device). This is the backstop that actually guarantees the + * bound. + * 3. **Hygiene** — [sanitizedWatermarks] drops rows that no longer validate, so a format + * change or a corrupted row cannot accumulate either. + * + * Deliberately NOT a rule: pruning to the ids returned by the latest `GET /live-sessions`. + * That looks tempting and is wrong — the fetch covers ONE host, so it would wipe every other + * host's watermarks on a host switch, and an offline/erroring host returns nothing at all + * (iOS records the same decision in `SessionListViewModel`). + * + * Also deliberately NOT a rule: a staleness/TTL sweep. Dropping an old watermark re-lights + * the dot for a session that is still alive and still unchanged — a *false* unread, which is + * worse than 25 KB. + */ +internal const val MAX_WATERMARKS: Int = 512 + +/** + * Drop everything untrustworthy: ids that are not valid wire session ids + * ([Validation.isValidSessionId]) and non-positive instants (`<= 0` means "never seen", which + * is already the default for a missing entry — storing it is pure waste). Valid ids are + * canonicalised to lower case and case-variant spellings of the same id collapse to their + * newest instant (they are the same session; `Validation` is case-insensitive, `UUID.toString` + * is lower case, and merging by max can never lower a watermark). Encounter order is kept. + */ +internal fun Map.sanitizedWatermarks(): Map { + val out = LinkedHashMap(size) + for ((id, atMs) in this) { + if (atMs <= 0L || !Validation.isValidSessionId(id)) continue + val key = id.lowercase() + val existing = out[key] + out[key] = if (existing == null) atMs else maxOf(existing, atMs) + } + return out +} + +/** + * Keep at most the [MAX_WATERMARKS] NEWEST watermarks, tie-broken by session id so the + * outcome is deterministic across runs (same rule as `UnreadLedger.capped`). Under the cap + * the receiver's own order is returned untouched; over it the result is newest-first, since a + * map that had to be truncated has no meaningful insertion order left to preserve. + */ +internal fun Map.cappedWatermarks(): Map { + if (size <= MAX_WATERMARKS) return this + return entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .take(MAX_WATERMARKS) + .associate { it.key to it.value } +} + +/** A new map without [sessionId]'s watermark (case-insensitive). Unknown id → unchanged contents. */ +internal fun Map.removingWatermark(sessionId: String): Map { + val target = sessionId.lowercase() + return filterKeys { it.lowercase() != target } +} + +/** The single write/read gate: sanitize, then cap. Everything crossing storage goes through it. */ +internal fun Map.forStorage(): Map = + sanitizedWatermarks().cappedWatermarks() diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStoreTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStoreTest.kt new file mode 100644 index 0000000..056ae0f --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStoreTest.kt @@ -0,0 +1,77 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Contract tests for the in-`main` [InMemorySessionWatermarkStore] double (the same role + * [InMemoryHostStore] / [InMemoryAuthCookieStore] play: it stands in for the DataStore + * store in the AW4 ViewModel tests). The double must enforce the SAME at-rest rules as + * the real store, otherwise a ViewModel test can pass against behaviour the device never + * exhibits. + */ +class InMemorySessionWatermarkStoreTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + @Test + fun `an empty store loads an empty map`() = runTest { + assertTrue(InMemorySessionWatermarkStore().loadAll().isEmpty()) + } + + @Test + fun `replaceAll then loadAll returns the same watermarks`() = runTest { + val store = InMemorySessionWatermarkStore() + + store.replaceAll(mapOf(idA to 10L, idB to 20L)) + + assertEquals(mapOf(idA to 10L, idB to 20L), store.loadAll()) + } + + @Test + fun `replaceAll returns exactly what was stored`() = runTest { + val store = InMemorySessionWatermarkStore() + + val stored = store.replaceAll(mapOf(idA to 10L, "garbage" to 20L)) + + assertEquals(mapOf(idA to 10L), stored) + assertEquals(stored, store.loadAll()) + } + + @Test + fun `replaceAll is a wholesale swap, not a merge`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L)) + + store.replaceAll(mapOf(idB to 20L)) + + assertEquals(mapOf(idB to 20L), store.loadAll()) + } + + @Test + fun `an initial map is sanitized too`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L, "garbage" to 20L)) + + assertEquals(mapOf(idA to 10L), store.loadAll()) + } + + @Test + fun `remove drops one session and returns the new map`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L, idB to 20L)) + + val remaining = store.remove(idA) + + assertEquals(mapOf(idB to 20L), remaining) + assertEquals(remaining, store.loadAll()) + } + + @Test + fun `removing an unknown session is a no-op`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L)) + + assertEquals(mapOf(idA to 10L), store.remove(idB)) + assertEquals(mapOf(idA to 10L), store.loadAll()) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodecTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodecTest.kt new file mode 100644 index 0000000..5c2c366 --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodecTest.kt @@ -0,0 +1,60 @@ +package wang.yaojia.webterm.hostregistry + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * At-rest codec tests (mirrors `HostCodecTest`): encode is total, decode is defensive — + * the blob is untrusted at rest, so anything undecodable reads back as "no watermarks" + * rather than crashing the session list on cold start. + */ +class SessionWatermarkCodecTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + @Test + fun `round-trips a watermark map`() { + val map = mapOf(idA to 1_700_000_000_000L, idB to 42L) + + assertEquals(map, SessionWatermarkCodec.decode(SessionWatermarkCodec.encode(map))) + } + + @Test + fun `an empty map round-trips to an empty map`() { + assertTrue(SessionWatermarkCodec.decode(SessionWatermarkCodec.encode(emptyMap())).isEmpty()) + } + + @Test + fun `null reads back as empty`() { + assertTrue(SessionWatermarkCodec.decode(null).isEmpty()) + } + + @Test + fun `blank reads back as empty`() { + assertTrue(SessionWatermarkCodec.decode(" ").isEmpty()) + } + + @Test + fun `a corrupt blob reads back as empty instead of throwing`() { + assertTrue(SessionWatermarkCodec.decode("{not json").isEmpty()) + } + + @Test + fun `a wrong-shaped blob reads back as empty`() { + // A JSON array where a map was written (e.g. a hand-edited or superseded format). + assertTrue(SessionWatermarkCodec.decode("""["$idA"]""").isEmpty()) + } + + @Test + fun `a non-numeric instant reads back as empty`() { + assertTrue(SessionWatermarkCodec.decode("""{"$idA":"yesterday"}""").isEmpty()) + } + + @Test + fun `decode does not itself validate ids - the store transforms do`() { + // Keeping the codec dumb keeps ONE place (sanitizedWatermarks) responsible for trust. + assertEquals(mapOf("garbage" to 5L), SessionWatermarkCodec.decode("""{"garbage":5}""")) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkTransformsTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkTransformsTest.kt new file mode 100644 index 0000000..d54e310 --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkTransformsTest.kt @@ -0,0 +1,150 @@ +package wang.yaojia.webterm.hostregistry + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Pure-transform tests for the watermark map shared by [InMemorySessionWatermarkStore] and + * [DataStoreSessionWatermarkStore] (mirrors `HostStoreTransformsTest` / + * `AuthCookieStoreTransformsTest`). These are the JVM-testable half of the storage split + * (plan §3: "the logic half of :host-registry" is in the Kover gate). + * + * Covers the three at-rest defences and the GC bound: + * - every stored session id is re-validated with the frozen `Validation.isValidSessionId`; + * - non-positive instants are dropped (a watermark of 0 means "never seen" anyway); + * - case-variant spellings of one id collapse via max (they are the same session); + * - the map is capped at [MAX_WATERMARKS], newest kept — what bounds growth. + */ +class SessionWatermarkTransformsTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + // ── sanitizedWatermarks() ───────────────────────────────────────────────── + + @Test + fun `a valid map survives sanitizing unchanged`() { + val map = mapOf(idA to 10L, idB to 20L) + + assertEquals(map, map.sanitizedWatermarks()) + } + + @Test + fun `a non-v4 session id is dropped`() { + // UUID v1 (version nibble 1) parses as a UUID but is not a valid wire session id. + val v1 = "3f2504e0-4f89-11d3-9a0c-0305e82c3301" + val map = mapOf(idA to 10L, v1 to 20L, "abc123" to 30L, "" to 40L) + + assertEquals(mapOf(idA to 10L), map.sanitizedWatermarks()) + } + + @Test + fun `non-positive instants are dropped`() { + val map = mapOf(idA to 0L, idB to -5L) + + assertTrue(map.sanitizedWatermarks().isEmpty()) + } + + @Test + fun `case-variant spellings of one id collapse to the newest`() { + val map = mapOf(idA to 10L, idA.uppercase() to 99L) + + assertEquals(mapOf(idA to 99L), map.sanitizedWatermarks()) + } + + @Test + fun `sanitizing preserves encounter order`() { + val map = linkedMapOf(idB to 20L, idA to 10L) + + assertEquals(listOf(idB, idA), map.sanitizedWatermarks().keys.toList()) + } + + @Test + fun `sanitizing never mutates the receiver`() { + val map = linkedMapOf(idA to 10L, "abc123" to 20L) + + map.sanitizedWatermarks() + + assertEquals(linkedMapOf(idA to 10L, "abc123" to 20L), map) + } + + // ── cappedWatermarks() ─────────────────────────────────────────────────── + + @Test + fun `a map at the cap is returned unchanged`() { + val map = watermarks(MAX_WATERMARKS) + + assertEquals(map, map.cappedWatermarks()) + } + + @Test + fun `over the cap the newest entries are kept`() { + val map = watermarks(MAX_WATERMARKS + 10) + + val capped = map.cappedWatermarks() + + assertEquals(MAX_WATERMARKS, capped.size) + // Instants ascend with the index, so the 10 oldest (lowest) must be the dropped ones. + assertFalse(capped.containsKey(sessionId(0))) + assertTrue(capped.containsKey(sessionId(MAX_WATERMARKS + 9))) + } + + @Test + fun `equal instants break the tie deterministically by session id`() { + val map = (0 until MAX_WATERMARKS + 2).associate { sessionId(it) to 7L } + + val capped = map.cappedWatermarks() + + assertEquals(MAX_WATERMARKS, capped.size) + assertEquals(map.cappedWatermarks().keys, capped.keys) // stable across runs + // Lowest ids win the tie-break, so the two highest ids are the dropped ones. + assertFalse(capped.containsKey(sessionId(MAX_WATERMARKS + 1))) + } + + // ── removingWatermark() ────────────────────────────────────────────────── + + @Test + fun `removing drops just that session`() { + val map = mapOf(idA to 10L, idB to 20L) + + assertEquals(mapOf(idB to 20L), map.removingWatermark(idA)) + } + + @Test + fun `removing an unknown session is a no-op`() { + val map = mapOf(idA to 10L) + + assertEquals(map, map.removingWatermark(idB)) + } + + @Test + fun `removing is case-insensitive like the id itself`() { + val map = mapOf(idA to 10L) + + assertTrue(map.removingWatermark(idA.uppercase()).isEmpty()) + } + + // ── forStorage() = sanitize + cap ──────────────────────────────────────── + + @Test + fun `forStorage sanitizes and caps in one pass`() { + val map = watermarks(MAX_WATERMARKS + 1) + mapOf("garbage" to 999_999L) + + val stored = map.forStorage() + + assertEquals(MAX_WATERMARKS, stored.size) + assertFalse(stored.containsKey("garbage")) + } + + // ── helpers ───────────────────────────────────────────────────────────── + + /** A distinct valid v4 id per [index] (the last 4 hex digits carry the index). */ + private fun sessionId(index: Int): String = + "3f2504e0-4f89-41d3-9a0c-0305e82c%04x".format(index) + + /** [count] valid watermarks whose instants ascend with the index (1-based, so all positive). */ + private fun watermarks(count: Int): Map = + (0 until count).associate { sessionId(it) to (it + 1).toLong() } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt index 472d53f..3c7f780 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt @@ -1,7 +1,9 @@ package wang.yaojia.webterm.terminalview import android.content.Context +import android.graphics.Canvas import android.util.TypedValue +import android.view.ActionMode import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent @@ -49,8 +51,11 @@ import kotlin.math.roundToInt * 4. **text selection** — `startTextSelectionMode` arms an `ActionMode` whose Copy * (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100) is * `mTermSession.onCopyTextToClipboard(...)` and whose Paste (offsets 126-136) is - * `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true, - * which is a **recorded deviation from plan §6.5** (see that method for the full rationale). + * `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true so + * the mode can never arm, and the press starts the **first-party** selection layer instead + * ([TerminalSelection] for the design; [beginSelectionAt] / [copySelection] here). Only the stock + * controller is replaced — the affordance is still an Android `ActionMode` over `ClipboardManager`, as + * plan §6.5 specifies. * * ### Stock-method audit (verified with `javap -p -c`; keep it current when the pinned version moves) * | stock member | reachable here? | verdict | @@ -68,6 +73,7 @@ import kotlin.math.roundToInt * | `updateSize` | no | early-returns without a session; [TerminalResizeDriver] replaces it | * | `attachSession` | no | never called — it is the process-forking path we replace | * | `startTextSelectionMode` / `TextSelectionCursorController` | **no** | long press consumed (4) | + * | `mDefaultSelectors` (the renderer's own highlight channel) | no (package-private) | our overlay draws it | * | `setTerminalCursorBlinker*` | never invoked | cosmetic gap vs stock: the cursor does not blink | * * ### The latent renderer crash this also fixes @@ -96,7 +102,10 @@ import kotlin.math.roundToInt * delegates to ([TerminalKeyDecoder], [TerminalInputBytes], [TerminalResizeDriver], [TerminalScrollGesture]) * 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. */ 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 stockView.topRow = min(NEWEST_ROW, max(-transcriptRows, stockView.topRow + step)) stockView.invalidate() + // The selection highlight is recorded in THIS view's display list and is positioned + // relative to `topRow`, so invalidating only the child would re-render scrolled glyphs + // under a stale band. Reachable with a selection alive: an external mouse wheel still + // scrolls (a selection lives in external rows, so moving the viewport is harmless). + this@RemoteTerminalHostView.invalidate() } override fun sendKeys(data: String) { @@ -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 { addView(stockView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) // 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) { stockView.mEmulator = emulator // A new emulator means a new transcript: drop any half-finished drag rather than let it resolve - // rows against a buffer it did not start in. + // rows against a buffer it did not start in, and drop any selection for the same reason — its rows + // name content that no longer exists. scrollGesture.reset() + selection.clear() } - /** Invalidate/redraw the stock view for the current emulator state (main thread). */ + /** + * Invalidate/redraw the stock view for the current emulator state (main thread), keeping a live + * selection pointing at the text it was made on. + * + * `TerminalView.onScreenUpdated` maintains a selection across incoming output — it shifts `mTopRow` by + * `getScrollCounter()` and decrements its selection cursors — but only when `isSelectingText()` is true, + * which for us is permanently false (we never arm the stock controller). What it does instead is reset + * `mTopRow` to 0 and CLEAR the scroll counter, so both halves of that bookkeeping have to happen here: + * read the counter first (nothing else can, afterwards), then re-pin the viewport and shift the span. + * Skipping it would leave the user's highlight over text that scrolled away — and Copy would then put + * something the user never selected on the clipboard. + */ internal fun requestScreenUpdate() { + val isSelecting = selection.isActive + val scrolledRows = if (isSelecting) stockView.mEmulator?.scrollCounter ?: NO_SCROLL else NO_SCROLL + val pinnedTopRow = if (isSelecting) stockView.topRow - scrolledRows else null + stockView.onScreenUpdated() + + if (pinnedTopRow != null) { + val oldestRow = -(stockView.mEmulator?.screen?.activeTranscriptRows ?: 0) + stockView.topRow = pinnedTopRow.coerceIn(oldestRow, NEWEST_ROW) + } + selection.onContentScrolled(scrolledRows) + invalidate() + } + + // ── Text-selection API (used by [RemoteTerminalView], which exposes it to `:app`) ────────────────── + + internal val isSelectingText: Boolean get() = selection.isActive + + /** + * Start a selection at these view pixels, snapped to the word under them. Answering false means there + * was no measured grid to resolve the pixels against, so the press is ignored. + */ + internal fun beginSelectionAt(xPx: Float, yPx: Float): Boolean { + val cell = cellAt(xPx, yPx) ?: return false + if (!selection.beginAt(cell)) return false + // `getScrollCounter()` accumulates until something clears it, and the only thing that does is + // `onScreenUpdated()`. A multi-chunk append posts one screen update for several main-thread appends, + // so a long press can land between a scroll and its update — and those rows scrolled BEFORE this + // selection existed. Counting them would shift the brand-new span by rows it never saw (measured: a + // selection two rows of stale counter old landed on the wrong line). Zeroing here makes the shift in + // [requestScreenUpdate] mean exactly "scrolled since the selection was made"; nothing else in the + // pinned artifact reads the counter (stock only reads it inside `isSelectingText()`, always false). + stockView.mEmulator?.clearScrollCounter() + return true + } + + /** THE explicit copy (plan §8): nothing else in this module writes terminal text to the clipboard. */ + internal fun copySelection(): TerminalCopyResult = selection.copy() + + internal fun clearSelection() { + selection.clear() + } + + /** The text currently highlighted — for tests and for `:app` to preview; reads the buffer, mutates nothing. */ + internal fun selectedText(): String = selection.selectedText() + + /** The highlight bands for the current selection, in this frame's pixel space. */ + internal fun highlightRects(): List { + val span = selection.selection?.span ?: return emptyList() + val bounds = selectionBuffer.bounds() ?: return emptyList() + val metrics = cellMetrics() ?: return emptyList() + return TerminalSelectionGeometry.highlightRects(span, bounds, metrics, stockView.topRow) + } + + /** + * A tap: dismiss a live selection, otherwise raise the soft keyboard. + * + * Dismissing takes precedence because a selection makes every drag an extend — the tap is how the user + * gets scrolling back, and it is the platform-standard way to end a selection. + */ + internal fun onTapped() { + if (selection.isActive) { + selection.clear() + return + } + showSoftKeyboard() + } + + private fun cellAt(xPx: Float, yPx: Float): TerminalCell? { + val metrics = cellMetrics() ?: return null + return TerminalSelectionGeometry.cellAt(xPx, yPx, metrics, stockView.topRow) + } + + private fun showCopyAffordance() { + val existing = copyAffordance + if (existing != null) { + // The span moved: let the floating toolbar re-anchor rather than stacking a second mode. + existing.invalidateContentRect() + return + } + copyAffordance = startActionMode( + TerminalSelectionActionMode( + contentRect = { highlightRects().firstOrNull() }, + onCopy = { + copySelection() + selection.clear() + }, + onDismissed = { + copyAffordance = null + selection.clear() + }, + ), + ActionMode.TYPE_FLOATING, + ) + } + + private fun hideCopyAffordance() { + // Null the field BEFORE finishing: finish() calls onDestroyActionMode, which calls back into + // selection.clear(). The controller's own "already clear" guard stops the recursion; this stops a + // second finish() on a dead mode. + val mode = copyAffordance ?: return + copyAffordance = null + mode.finish() + } + + /** + * Draw the children (the stock view paints every glyph) and then the selection highlight ON TOP. + * + * An overlay, not a renderer patch: plan §6.1 keeps rendering stock, and the renderer's own selection + * channel (`TerminalView.mDefaultSelectors`) is package-private to `com.termux.view` — see + * [TerminalSelectionPainter] for why reaching it was rejected. + */ + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + selectionPainter.draw(canvas, highlightRects()) } /** @@ -287,12 +458,29 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra 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) + // A new size means a new grid and an emulator reflow, so the cells the span names are about to hold + // different text. Dropping the selection is the honest response — re-mapping it across a reflow is + // guesswork, and keeping it would let Copy return text the user never highlighted. + selection.clear() sink?.onViewSize(width, height) } + /** + * With a live selection every drag EXTENDS it; otherwise the drag goes to the scroll gesture. + * + * The branch is what makes the feature usable on a touch screen: without it the very drag that should + * grow the selection would instead be claimed by [TerminalScrollGesture] and — inside an + * alternate-screen TUI — typed into the shell as arrow keys. + */ private fun routeTouch(event: MotionEvent): Boolean { + if (selection.isActive) return routeSelectionTouch(event) eventInFlight = event try { return scrollGesture.onTouch(TouchFacts.of(event)) @@ -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 * 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). */ const val NO_DOWN_TIME: Long = -1L + /** Nothing scrolled off the top since the last screen update. */ + const val NO_SCROLL: Int = 0 + + /** A second pointer means a pinch, not a drag. */ + const val SINGLE_POINTER: Int = 1 + /** `getColumnAndRow` returns `[column, row]`, both 0-based; the wire protocol is 1-based. */ const val COLUMN_INDEX: Int = 0 const val ROW_INDEX: Int = 1 diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt index be43464..132bd30 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt @@ -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 * implementations dereference the absent `TerminalSession`, so [RemoteTerminalHostView] owns vertical - * drags and the long press is consumed ([WebTermTerminalViewClient.onLongPress], a recorded §6.5 - * deviation). [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member: which are - * reachable, which are proven safe, and which had to be intercepted. + * drags, and the long press is consumed ([WebTermTerminalViewClient.onLongPress]) and re-spent on a + * first-party selection layer ([TerminalSelectionController]) rather than on the stock `ActionMode`, whose + * Copy button is an NPE. [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member: + * which are reachable, which are proven safe, and which had to be intercepted. * * ### What this class owns * 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] → * [RemoteTerminalSession.updateSize], which is the ONLY reason the server PTY ever learns the real * grid: `TerminalView.updateSize()` early-returns without a session (offsets 18–25). + * - **selection** — [WebTermTerminalViewClient.onLongPress] → [RemoteTerminalHostView.beginSelectionAt], + * then [copySelection] → `ClipboardManager`. Paste is untouched: it arrives through the IME. * - * DEVICE-QA (plan §7): rendering, IME/CJK composition, selection, link taps, focus behaviour and grid - * parity against web/iOS on the same physical size are verified on a device (risk R5). + * DEVICE-QA (plan §7): rendering, IME/CJK composition, link taps, focus behaviour and grid parity against + * web/iOS on the same physical size are verified on a device (risk R5) — and for selection specifically, + * the floating Copy toolbar, the drag feel and the highlight's alignment with the glyphs. */ public class RemoteTerminalView(context: Context) { @@ -55,7 +59,11 @@ public class RemoteTerminalView(context: Context) { } 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) { @@ -107,11 +115,38 @@ public class RemoteTerminalView(context: Context) { /** Layout-change outlet (A17/A21 install point) — the §6.4 device-switch reclaim hook. */ 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 { terminalView.sink = sink // Installed BEFORE any emulator binds: touch, focus and the IME can all arrive first, and every // one of those stock paths dereferences `mClient` without a null check. terminalView.installClient(WebTermTerminalViewClient(sink)) + terminalView.onSelectionChanged = { isSelecting -> onSelectionChanged?.invoke(isSelecting) } + } + + // ── Copy-out (plan §6.5). The gesture path is self-contained; these are for `:app`'s own UI ──────── + + /** True while text is highlighted. */ + public val isSelectingText: Boolean get() = terminalView.isSelectingText + + /** + * Copy the highlighted text to the device clipboard. This is the ONLY path from terminal content to the + * clipboard (plan §8): selecting copies nothing, and OSC 52 host-clipboard writes stay declined. + * + * Safe to call with nothing selected — it answers [TerminalCopyResult.NOTHING_SELECTED] rather than + * clearing whatever the user already had. + */ + public fun copySelection(): TerminalCopyResult = terminalView.copySelection() + + /** Drop the selection (also what a tap on the terminal does). */ + public fun clearSelection() { + terminalView.clearSelection() } /** Point the stock renderer at the forked emulator, then re-assert the measured grid on it. */ diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalClipboard.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalClipboard.kt new file mode 100644 index 0000000..576a1e8 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalClipboard.kt @@ -0,0 +1,76 @@ +package wang.yaojia.webterm.terminalview + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.PersistableBundle +import android.util.Log + +/** + * The device clipboard, behind a one-method seam so the copy DECISION is unit-testable without a framework + * clipboard and so there is exactly one place in the module that can write terminal text out of the app. + */ +internal interface TerminalClipboard { + + /** @return true when [text] is now on the clipboard; false when the platform refused. */ + fun put(text: String): Boolean +} + +/** + * [TerminalClipboard] over `android.content.ClipboardManager`. + * + * ### Security posture (plan §8, §6.5) + * Terminal output is untrusted and may be a secret — an API key echoed by a script, a token in a log line. + * Two rules follow and both are enforced here rather than trusted to callers: + * + * - **Only an explicit user copy reaches this class.** There is no automatic path: OSC 52 host-clipboard + * writes stay declined in [RemoteTerminalSession] (`onCopyTextToClipboard` is a no-op), and + * [TerminalSelectionController] refuses to write without a live selection. Selecting text writes nothing. + * - **The copied text is never logged.** Failures log the throwable TYPE only, matching + * [RemoteTerminalSession]'s precedent; the payload never reaches logcat. + * + * The clip is also marked `EXTRA_IS_SENSITIVE` on API 33+, which suppresses the system clipboard preview + * overlay. The user asked for the text, but nobody asked for it to be rendered over their screen for a + * bystander — and this class cannot tell a filename from a password, so it treats every terminal copy as + * sensitive. + */ +internal class SystemTerminalClipboard(private val context: Context) : TerminalClipboard { + + override fun put(text: String): Boolean { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (clipboard == null) { + Log.w(LOG_TAG, "no clipboard service available; nothing copied") + return false + } + val clip = ClipData.newPlainText(CLIP_LABEL, text).apply { markSensitive() } + return try { + clipboard.setPrimaryClip(clip) + true + } catch (e: RuntimeException) { + // OEM clipboard services throw here (SecurityException when not the focused app, + // IllegalStateException / TransactionTooLargeException on a very large clip). A refused copy is + // a reportable outcome, never a crash. Type only — the clip content must not reach logcat. + Log.w(LOG_TAG, "clipboard refused the copy: ${e.javaClass.simpleName}") + false + } + } + + private fun ClipData.markSensitive() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + + private companion object { + /** + * Shown by the system clipboard UI. Deliberately generic: it must not describe WHICH session or + * host the text came from. + */ + const val CLIP_LABEL: String = "Terminal selection" + + const val LOG_TAG: String = "TerminalSelection" + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt index 0f0598b..f4ce42d 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt @@ -26,9 +26,23 @@ internal interface TerminalInputSink { /** Raw bytes onto the wire, verbatim (invariant #9 — never filtered). */ 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() + /** + * The user long-pressed at these view pixels: start a text selection there + * ([RemoteTerminalHostView.beginSelectionAt]). + * + * The long press stays CONSUMED by [WebTermTerminalViewClient] either way — this is a notification, not + * a decision. Stock text selection must remain unreachable (its Copy button dereferences the absent + * `TerminalSession`), so the press is spent on the first-party layer instead of being handed back. + */ + fun onLongPressAt(xPx: Float, yPx: Float) + /** The host view was laid out at this pixel size (drives the §6.4 resize path). */ fun onViewSize(widthPx: Int, heightPx: Int) } diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionActionMode.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionActionMode.kt new file mode 100644 index 0000000..336a0a1 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionActionMode.kt @@ -0,0 +1,76 @@ +package wang.yaojia.webterm.terminalview + +import android.graphics.Rect +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import android.view.View +import kotlin.math.roundToInt + +/** + * The explicit Copy affordance: a floating `ActionMode` over the selection. + * + * This is plan §6.5's "Android `ActionMode` → `ClipboardManager`" with the one part that could not be kept — + * `TextSelectionCursorController` — replaced. It is our own `ActionMode.Callback2` on our own frame, so the + * menu it builds has one item that calls [onCopy], and the stock callback whose Copy is + * `mTermSession.onCopyTextToClipboard(...)` is never constructed. `TYPE_FLOATING` + [onGetContentRect] is the + * same shape stock uses (`startActionMode(callback, 1)`), which is what makes the toolbar hover over the + * selected text rather than take over the app bar. + * + * Paste is deliberately absent: it already works through the IME + * ([RemoteTerminalInputConnection] → `writeInput`), and a paste item here would only duplicate it. + * + * @param contentRect the selection's bounding box in view pixels, or null when nothing is highlighted. + * @param onCopy fired for the Copy item. The caller both copies and dismisses. + * @param onDismissed fired when the mode ends for ANY reason (Copy, back, tapping away). + */ +internal class TerminalSelectionActionMode( + private val contentRect: () -> TerminalSelectionRect?, + private val onCopy: () -> Unit, + private val onDismissed: () -> Unit, +) : ActionMode.Callback2() { + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + // android.R.string.copy is the platform's own localised "Copy", so :terminal-view needs no + // resources of its own for this. + menu.add(Menu.NONE, COPY_ITEM_ID, Menu.NONE, android.R.string.copy) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + return true + } + + /** Nothing to re-prepare: the single item is always enabled while a selection exists. */ + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + if (item.itemId != COPY_ITEM_ID) return false + onCopy() + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + onDismissed() + } + + /** + * Where the floating toolbar should point. Falls back to the platform default (the whole view) when the + * selection has scrolled out of sight, which is better than anchoring the toolbar off-screen. + */ + override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) { + val rect = contentRect() + if (rect == null) { + super.onGetContentRect(mode, view, outRect) + return + } + outRect.set( + rect.left.roundToInt(), + rect.top.roundToInt(), + rect.right.roundToInt(), + rect.bottom.roundToInt(), + ) + } + + private companion object { + /** Any non-zero id; there is only ever one item. */ + const val COPY_ITEM_ID: Int = 1 + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBuffer.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBuffer.kt new file mode 100644 index 0000000..82e7c53 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBuffer.kt @@ -0,0 +1,145 @@ +package wang.yaojia.webterm.terminalview + +import android.util.Log +import com.termux.terminal.TerminalEmulator + +/** + * Read-only access to whatever the emulator currently holds — the ONE seam the selection layer reads the + * terminal through. + * + * Two operations, both pure reads: what grid exists right now, and what text a span covers. Keeping it to + * an interface is what lets the state machine ([TerminalSelectionController]) be driven by a fake, while + * the real implementation is pinned against a real `TerminalEmulator` in the same JVM suite. + */ +internal interface TerminalSelectionBuffer { + + /** The live grid, or null before an emulator is bound. */ + fun bounds(): TerminalGridBounds? + + /** + * The text inside [span]. Never throws: an off-grid span is clamped and a missing emulator yields "". + * Implementations MUST NOT mutate anything (see [TerminalEmulatorSelectionBuffer] for why). + */ + fun textIn(span: TerminalSelectionSpan): String +} + +/** + * [TerminalSelectionBuffer] over the bound Termux emulator. + * + * ### Threading — how a read is kept off a concurrent mutation (§6.2) + * Every method here runs on the **main (render) thread**: the callers are touch/long-press/ActionMode + * callbacks and `dispatchDraw`. Since the 2026-07-30 repair, EVERY emulator mutation also runs on the main + * thread — [RemoteTerminalSession]'s single confined consumer hops to `mainDispatcher` for the `append` and + * the `resize` and does nothing to the emulator anywhere else. So a read and a mutation are two main-thread + * work items and **cannot interleave at all**. That is the same guarantee upstream Termux relies on + * (`TerminalSession$MainThreadHandler.handleMessage` calls `append`), and it is the only one available: + * `monitorenter` appears nowhere in `TerminalEmulator`, `TerminalBuffer`, `TerminalRow` or + * `TerminalRenderer`, so there is no lock to take. + * + * It is worth being explicit about why "read it anyway and let a torn read self-correct" is not on the + * table: that exact assumption was in this module's own KDoc until 2026-07-30 and it was wrong — a draw + * landing inside `TerminalEmulator.resize`'s window (new `mColumns` published at offsets 97-106, rows + * reallocated at offset 160) threw `ArrayIndexOutOfBoundsException` out of `TerminalRow.getStyle` and + * killed the process on a real emulator. `getSelectedText` indexes the same `mText`/`mLines` arrays through + * the same unsynchronised path, so a torn read here would be the same crash with the same lack of a + * self-correcting next frame. **Do not move emulator mutation off the main thread**, and do not call these + * methods from anywhere but the main thread. + * + * ### Why extraction is delegated rather than reimplemented + * `TerminalBuffer.getSelectedText(x1, y1, x2, y2)` is the one part of the stock selection stack that never + * touches `TerminalSession` — it walks rows via `TerminalRow.findStartOfColumn`, which is `WcWidth`-aware + * (so a wide CJK cell copies whole from either of its two columns), trims each line's trailing blanks, and + * joins soft-wrapped rows while preserving real line breaks. Re-deriving that in Kotlin would duplicate + * subtle logic that must agree with what the renderer draws. `TerminalSelectionBufferTest` pins the + * behaviour so a Termux bump that changes it fails loudly instead of silently copying the wrong bytes. + */ +internal class TerminalEmulatorSelectionBuffer( + private val emulator: () -> TerminalEmulator?, +) : TerminalSelectionBuffer { + + override fun bounds(): TerminalGridBounds? { + val emulator = emulator() ?: return null + return TerminalGridBounds( + columns = emulator.mColumns, + screenRows = emulator.mRows, + transcriptRows = emulator.screen.activeTranscriptRows, + ) + } + + override fun textIn(span: TerminalSelectionSpan): String { + val emulator = emulator() ?: return "" + val bounds = bounds() ?: return "" + // Clamping INSIDE the port is what makes the two unguarded stock failures unreachable for every + // caller: TerminalBuffer.externalToInternalRow throws IllegalArgumentException outside + // -activeTranscriptRows..screenRows, and TerminalRow.findStartOfColumn walks mText with no bound + // check for any column > mColumns. See TerminalSelectionSpan.clampedTo. + val clamped = span.clampedTo(bounds) ?: return "" + return try { + emulator.getSelectedText( + clamped.start.column, + clamped.start.row, + clamped.end.column, + clamped.end.row, + ) + } catch (e: IllegalArgumentException) { + // Should be unreachable after the clamp; a throw here would otherwise kill the process on the + // UI thread. Log the throwable TYPE only — never the message, which can carry row content. + logDroppedRead(e) + "" + } catch (e: IndexOutOfBoundsException) { + logDroppedRead(e) + "" + } + } + + private fun logDroppedRead(throwable: Throwable) { + Log.w(LOG_TAG, "dropping a selection read that threw: ${throwable.javaClass.simpleName}") + } + + private companion object { + private const val LOG_TAG: String = "TerminalSelection" + } +} + +/** + * The long-press snap: the run of non-blank cells around the pressed cell, so one press-and-release already + * selects something worth copying (a path, a hash, a container id) instead of a single character. + * + * ### Deliberately not stock's expansion + * `TextSelectionCursorController.setInitialTextSelectionPosition` (offsets 34-189) walks outward while the + * neighbouring cell's text `!= " "` — but a blank cell's `getSelectedText` is `""`, not `" "`, because + * trailing blanks are trimmed. So the stock comparison never matches and the expansion runs to both row + * edges: in Termux a long press selects the whole line. Testing for *blank* instead gives word-granular + * selection, which is what a shell line is actually made of; the user can still drag out to the rest of + * the line. + */ +internal object TerminalSelectionWords { + + /** + * The selection a long press at [cell] should start with, or null when there is no grid yet. + * + * The anchor is the START of the run and the focus its END, so a subsequent drag backwards past the + * word start flips the span the way a text editor does. + */ + fun runAt(cell: TerminalCell, buffer: TerminalSelectionBuffer): TerminalSelection? { + val bounds = buffer.bounds() ?: return null + if (!bounds.isMeasured) return null + val pressed = bounds.clamp(cell) + if (isBlank(pressed, buffer)) return TerminalSelection.at(pressed) + + var first = pressed.column + while (first > 0 && !isBlank(pressed.copy(column = first - 1), buffer)) first-- + + var last = pressed.column + while (last < bounds.lastColumn && !isBlank(pressed.copy(column = last + 1), buffer)) last++ + + return TerminalSelection.anchoredOn( + from = pressed.copy(column = first), + to = pressed.copy(column = last), + ) + } + + /** A cell is blank when its extracted text is empty (trailing-blank trimming) or whitespace. */ + private fun isBlank(cell: TerminalCell, buffer: TerminalSelectionBuffer): Boolean = + buffer.textIn(TerminalSelectionSpan(cell, cell)).isBlank() +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionController.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionController.kt new file mode 100644 index 0000000..b668f0e --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionController.kt @@ -0,0 +1,124 @@ +package wang.yaojia.webterm.terminalview + +/** What an explicit copy did. Public so `:app` can report the outcome in its own UI. */ +public enum class TerminalCopyResult { + /** The selected text is on the clipboard. */ + COPIED, + + /** Nothing was selected, so there was nothing to copy. */ + NOTHING_SELECTED, + + /** The selection resolved to blank cells; the clipboard was deliberately left alone. */ + NOTHING_TO_COPY, + + /** The platform clipboard refused the write (see [SystemTerminalClipboard]). */ + CLIPBOARD_UNAVAILABLE, +} + +/** + * The selection state machine: what a long press starts, what a drag does to it, what new output does to + * it, and the single path from a live selection to the clipboard. + * + * Pure of android and of Termux — the terminal arrives through [TerminalSelectionBuffer] and leaves through + * [TerminalClipboard], so every decision here is JVM-unit-testable. The view layer + * ([RemoteTerminalHostView]) owns only pixels→cells and the affordance. + * + * Not thread-safe by design: like [TerminalResizeDriver] it is driven from touch and draw callbacks, i.e. + * the main thread only. That is also the thread the emulator is mutated on, which is what makes the buffer + * reads safe (see [TerminalEmulatorSelectionBuffer]). + * + * @param onChanged fired with the new "is a selection active" value whenever it or the span changes. The + * view uses it to repaint the highlight and show/hide the Copy affordance. + */ +internal class TerminalSelectionController( + private val buffer: TerminalSelectionBuffer, + private val clipboard: TerminalClipboard, + private val onChanged: (isActive: Boolean) -> Unit, +) { + /** The live selection, or null when there is none. Immutable — every change replaces it. */ + var selection: TerminalSelection? = null + private set + + val isActive: Boolean get() = selection != null + + /** + * Start a selection at [cell], snapped to the word under it ([TerminalSelectionWords]). + * + * @return true when a selection now exists. False means the grid is not measured yet (nothing is bound, + * or the view has not been laid out), in which case the long press is simply ignored. + */ + fun beginAt(cell: TerminalCell): Boolean { + val started = TerminalSelectionWords.runAt(cell, buffer) ?: return false + publish(started) + return true + } + + /** Move the focus end to [cell] (clamped into the live grid). No-op without a selection. */ + fun extendTo(cell: TerminalCell) { + val current = selection ?: return + val bounds = buffer.bounds() ?: return + if (!bounds.isMeasured) return + publish(current.withFocus(bounds.clamp(cell))) + } + + /** + * Drop the selection. + * + * Reports nothing when there was no selection — load-bearing, because the Copy affordance is dismissed + * BY this method and its own dismissal callback calls it back; without the guard that is an infinite + * loop (see [RemoteTerminalHostView]'s action-mode handling). + */ + fun clear() { + if (selection == null) return + selection = null + onChanged(false) + } + + /** The text the current selection covers, or "" when there is none. Reads the buffer; mutates nothing. */ + fun selectedText(): String { + val span = selection?.span ?: return "" + return buffer.textIn(span) + } + + /** + * THE explicit copy — the only path from terminal content to the device clipboard (plan §8). + * + * A blank result is refused rather than written: replacing whatever the user had on their clipboard with + * an empty string is data loss, and it is the likely outcome of a stray long press on empty space. + */ + fun copy(): TerminalCopyResult { + if (selection == null) return TerminalCopyResult.NOTHING_SELECTED + val text = selectedText() + if (text.isBlank()) return TerminalCopyResult.NOTHING_TO_COPY + return if (clipboard.put(text)) TerminalCopyResult.COPIED else TerminalCopyResult.CLIPBOARD_UNAVAILABLE + } + + /** + * [scrolledRows] new rows scrolled off the top of the screen, so every external row index the selection + * holds is now that much smaller. + * + * Called from the screen-update path with `TerminalEmulator.getScrollCounter()` read BEFORE the stock + * `onScreenUpdated()` clears it — the stock view only maintains this for its own selection controller + * (`isSelectingText()`, always false here), so the shift has to be applied by us or the highlight ends + * up pointing at different text than the user selected. Once the top of the selection falls past the + * oldest retained transcript row the text is genuinely gone, and the selection is dropped rather than + * silently re-pointed — mirroring stock's `stopTextSelectionMode()` in the same situation. + */ + fun onContentScrolled(scrolledRows: Int) { + if (scrolledRows == 0) return + val current = selection ?: return + val bounds = buffer.bounds() ?: return + val moved = current.movedBy(-scrolledRows) + if (moved.span.start.row < bounds.firstRow) { + clear() + return + } + publish(moved) + } + + private fun publish(next: TerminalSelection) { + if (next == selection) return + selection = next + onChanged(true) + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometry.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometry.kt new file mode 100644 index 0000000..9b12011 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometry.kt @@ -0,0 +1,80 @@ +package wang.yaojia.webterm.terminalview + +import kotlin.math.floor + +/** One highlight band, in the host frame's own pixel space (which is the stock child's — it sits at 0,0). */ +internal data class TerminalSelectionRect( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, +) + +/** + * Pixels ↔ cells for the selection layer. Pure: the caller supplies the measured cell box and the + * viewport's `topRow`, so both directions are JVM-unit-testable without a device — the same trick + * [TerminalGridMath] plays for the resize formula. + * + * ### The mapping, and why it is not stock's + * This is the exact inverse of the stock accessors this module already reads: + * `TerminalView.getPointX(col)` is `round(col * fontWidth)` and `getPointY(row)` is + * `(row - topRow) * lineSpacing`, i.e. cell `(c, r)` owns the band + * `[c*fontWidth, (c+1)*fontWidth) x [(r-topRow)*lineSpacing, (r-topRow+1)*lineSpacing)`. `TerminalRenderer` + * draws each row's baseline inside that same band (`y += mFontLineSpacing` per row, starting at + * `mFontLineSpacingAndAscent`), so a highlight built this way lines up with the glyphs. + * + * Stock's own hit test ([com.termux.view.TerminalView.getColumnAndRow]) instead computes + * `(y - mFontLineSpacingAndAscent) / mFontLineSpacing`, which subtracts the (negative) font ascent and so + * lands roughly a fifth of a row above the glyph the user pressed. Reproducing that would make a long + * press select the line above itself near a row boundary, so it is deliberately not reproduced. + */ +internal object TerminalSelectionGeometry { + + /** + * The cell under a touch, or null when the renderer has not measured a cell box yet (before the first + * layout `fontWidth`/`lineSpacing` are 0 and there is no grid to divide by). + * + * The result is NOT clamped — a touch outside the grid legitimately resolves to an off-grid cell, and + * clamping happens once, against the live bounds, in [TerminalSelectionSpan.clampedTo]. + */ + fun cellAt(xPx: Float, yPx: Float, metrics: TerminalCellMetrics, topRow: Int): TerminalCell? { + if (metrics.fontWidthPx <= 0f || metrics.lineSpacingPx <= 0) return null + return TerminalCell( + column = floor(xPx / metrics.fontWidthPx).toInt(), + row = floor(yPx / metrics.lineSpacingPx).toInt() + topRow, + ) + } + + /** + * One band per VISIBLE row of [span]. + * + * Rows above or below the viewport are dropped rather than emitted with an off-screen `top`: a + * selection made in scrollback and then scrolled away must paint nothing, and a 10 000-row transcript + * selection must not build 10 000 rects for a 24-row screen. + */ + fun highlightRects( + span: TerminalSelectionSpan, + bounds: TerminalGridBounds, + metrics: TerminalCellMetrics, + topRow: Int, + ): List { + if (!bounds.isMeasured) return emptyList() + if (metrics.fontWidthPx <= 0f || metrics.lineSpacingPx <= 0) return emptyList() + + val firstVisibleRow = maxOf(span.start.row, topRow) + val lastVisibleRow = minOf(span.end.row, topRow + bounds.screenRows - 1) + if (firstVisibleRow > lastVisibleRow) return emptyList() + + return (firstVisibleRow..lastVisibleRow).mapNotNull { row -> + val columns = span.columnsIn(row, bounds.lastColumn) ?: return@mapNotNull null + val top = (row - topRow) * metrics.lineSpacingPx.toFloat() + TerminalSelectionRect( + left = columns.first * metrics.fontWidthPx, + top = top, + // Inclusive columns → the band covers the whole last cell, hence the +1. + right = (columns.last + 1) * metrics.fontWidthPx, + bottom = top + metrics.lineSpacingPx, + ) + } + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModel.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModel.kt new file mode 100644 index 0000000..0efb929 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModel.kt @@ -0,0 +1,169 @@ +package wang.yaojia.webterm.terminalview + +/** + * The first-party text-selection model — cell coordinates, normalisation and clamping. + * + * ### Why this exists at all (the stock selection UI is a crash, not a feature) + * `TerminalView.startTextSelectionMode` arms an `ActionMode` whose own handlers dereference the + * `TerminalSession` this app deliberately does not have: + * `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 are + * `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 are `mTermSession.onPasteTextFromClipboard()` + * (re-verified with `javap -p -c` on the pinned `terminal-view-v0.118.0.aar`). So making the stock UI + * reachable buys a selection whose **Copy button is an NPE**, which is strictly worse than no selection — + * the user loses their work to a process kill instead of finding the affordance absent. `mTermSession` + * cannot be supplied: `TerminalSession` is `final` and its constructor forks a local process, which is + * exactly what this app must not do (plan §6.1). + * + * The one thing in that stock stack that IS session-free is the extraction — + * `TerminalBuffer.getSelectedText(x1, y1, x2, y2)` reads rows and nothing else. So the replacement is: our + * own coordinates (this file), our own hit-testing and highlight ([TerminalSelectionGeometry]), stock + * extraction ([TerminalSelectionBuffer]) and a direct `ClipboardManager` write ([TerminalClipboard]) — + * `TextSelectionCursorController` bypassed entirely, `onLongPress` still consuming so the stock mode can + * never arm. + * + * Everything in this file is pure and immutable: no android, no Termux, no mutation. Every operation + * returns a new value. + */ + +/** + * One terminal cell, ordered **row-major** — which is what makes a drag up-and-right still a backwards + * drag, and lets [TerminalSelectionSpan] normalise with plain `minOf`/`maxOf`. + * + * @param column 0-based, left to right. + * @param row an **external** row in Termux's sense: `0 .. screenRows-1` is the live screen and negative + * rows walk back into the scrollback transcript. External rows are independent of the viewport's + * `topRow`, so scrolling the view never moves a selection — but new output does (see [movedBy]). + */ +internal data class TerminalCell(val column: Int, val row: Int) : Comparable { + + override fun compareTo(other: TerminalCell): Int = + if (row != other.row) row.compareTo(other.row) else column.compareTo(other.column) + + fun movedBy(rows: Int): TerminalCell = copy(row = row + rows) +} + +/** + * An **inclusive**, row-major-ordered cell range: [start] is never after [end]. + * + * That invariant is established by construction — every producer ([spanning], [clampedTo], [expandedTo]) + * orders its output — and it is what lets [columnsIn] and the buffer read treat start/end as first/last + * without re-checking. + */ +internal data class TerminalSelectionSpan(val start: TerminalCell, val end: TerminalCell) { + + val isSingleCell: Boolean get() = start == end + + /** The rows this span touches, oldest (smallest) first. */ + val rows: IntRange get() = start.row..end.row + + /** + * The columns selected on [row], or null when [row] is outside the span. + * + * The first row runs from its start column to the grid edge, the last row from column 0 to its end + * column, and every row between is whole — matching `TerminalBuffer.getSelectedText`'s own per-row + * range (offsets 57-98), so the highlight and the copied text can never disagree. + */ + fun columnsIn(row: Int, lastColumn: Int): IntRange? { + if (row < start.row || row > end.row) return null + val first = if (row == start.row) start.column else 0 + val last = if (row == end.row) end.column else lastColumn + return first..last + } + + /** This span grown to include [cell]. A cell already inside it changes nothing. */ + fun expandedTo(cell: TerminalCell): TerminalSelectionSpan = + TerminalSelectionSpan(minOf(start, cell), maxOf(end, cell)) + + fun movedBy(rows: Int): TerminalSelectionSpan = + TerminalSelectionSpan(start.movedBy(rows), end.movedBy(rows)) + + /** + * This span clamped into [bounds], or null when the grid has not been measured yet. + * + * **Load-bearing, not defensive dressing.** An unclamped cell reaches two unguarded stock paths: + * `TerminalBuffer.externalToInternalRow` THROWS `IllegalArgumentException` for a row outside + * `-activeTranscriptRows .. screenRows`, and `TerminalRow.findStartOfColumn` walks `mText` with **no + * bound check** for any `column > mColumns` (offsets 13-173 — only the exact `column == mColumns` case + * short-circuits), so an off-grid column runs off the end of the char array. A touch a few pixels past + * the last cell, or a drag that leaves the view, produces exactly those inputs. + */ + fun clampedTo(bounds: TerminalGridBounds): TerminalSelectionSpan? { + if (!bounds.isMeasured) return null + return TerminalSelectionSpan(bounds.clamp(start), bounds.clamp(end)) + } + + companion object { + /** The normalising factory: the two cells in either order produce the same span. */ + fun spanning(one: TerminalCell, other: TerminalCell): TerminalSelectionSpan = + TerminalSelectionSpan(minOf(one, other), maxOf(one, other)) + } +} + +/** + * A live selection as the user's gesture describes it. + * + * @param anchor what the long press claimed — a **span**, not a cell, because a press snaps to the word + * under it ([TerminalSelectionWords]). Keeping the whole anchor means a drag backwards past the word + * start still leaves that word selected, the way a text editor behaves, instead of collapsing it to the + * single cell the drag passed through. + * @param focus where the finger is now. + */ +internal data class TerminalSelection(val anchor: TerminalSelectionSpan, val focus: TerminalCell) { + + /** + * The normalised, inclusive span: the anchor grown to reach the focus. A backwards drag yields exactly + * the same span as the equivalent forwards one — [TerminalSelectionSpan.expandedTo] orders by + * construction, so there is no direction to get wrong. + */ + val span: TerminalSelectionSpan get() = anchor.expandedTo(focus) + + fun withFocus(cell: TerminalCell): TerminalSelection = copy(focus = cell) + + /** + * Shift the whole selection by [rows] (negative == the screen scrolled up under a live selection). + * + * New output renumbers external rows: the line the user selected is one row lower after each new line + * arrives. Without this shift the highlight would keep pointing at the same coordinates while the text + * under it changed — and the Copy button would then put *different* text on the clipboard than the one + * highlighted, which for terminal content (possibly secrets) is a correctness bug, not a cosmetic one. + */ + fun movedBy(rows: Int): TerminalSelection = + TerminalSelection(anchor.movedBy(rows), focus.movedBy(rows)) + + companion object { + /** A selection of one cell — the fallback when a press lands on blank space. */ + fun at(cell: TerminalCell): TerminalSelection = + TerminalSelection(TerminalSelectionSpan(cell, cell), cell) + + /** A selection anchored on a whole run of cells, focused at its end. */ + fun anchoredOn(from: TerminalCell, to: TerminalCell): TerminalSelection = + TerminalSelection(TerminalSelectionSpan.spanning(from, to), maxOf(from, to)) + } +} + +/** + * The live grid a selection is resolved against: the emulator's `mColumns` / `mRows` and its buffer's + * `activeTranscriptRows`. Read fresh for every operation (the grid changes on resize and the transcript + * grows with output), never cached. + */ +internal data class TerminalGridBounds( + val columns: Int, + val screenRows: Int, + val transcriptRows: Int, +) { + /** False before the first layout/bind, when there is no grid to clamp into. */ + val isMeasured: Boolean get() = columns > 0 && screenRows > 0 + + val lastColumn: Int get() = columns - 1 + + /** The oldest retained row; `getSelectedText` clamps to the same floor (offsets 15-29). */ + val firstRow: Int get() = -transcriptRows + + /** The newest row of the live screen; `getSelectedText` clamps to the same ceiling (offsets 30-46). */ + val lastRow: Int get() = screenRows - 1 + + fun clamp(cell: TerminalCell): TerminalCell = TerminalCell( + column = cell.column.coerceIn(0, lastColumn), + row = cell.row.coerceIn(firstRow, lastRow), + ) +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionPainter.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionPainter.kt new file mode 100644 index 0000000..781e1a9 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionPainter.kt @@ -0,0 +1,44 @@ +package wang.yaojia.webterm.terminalview + +import android.graphics.Canvas +import android.graphics.Paint + +/** + * Paints the selection highlight as an OVERLAY, on top of the stock glyphs. + * + * ### Why an overlay rather than the renderer's own selection channel + * `TerminalRenderer.render` already knows how to invert selected cells — `TerminalView.onDraw` passes it + * four selector ints and the renderer flags every column between them. That channel is + * `TerminalView.mDefaultSelectors`, which is **package-private** to `com.termux.view`, so reaching it would + * take either reflection (silently broken by a version bump, and the module already refused reflection for + * the cell metrics) or a source file planted in Termux's package. Neither is worth it for a highlight, and + * plan §6.1 keeps rendering stock: nothing here patches `TerminalRenderer` or `TerminalView.onDraw`. + * + * A translucent fill drawn after the children therefore does the job. It is drawn in the host frame's own + * coordinate space, which is the stock child's — the child fills the frame at (0,0) and `onDraw` applies no + * canvas translation, so the bands line up with the glyphs cell for cell. + */ +internal class TerminalSelectionPainter { + + private val fill = Paint().apply { + color = SELECTION_ARGB + style = Paint.Style.FILL + // Cell-aligned rectangles: smoothing their edges only blurs the boundary between two selected rows. + isAntiAlias = false + } + + fun draw(canvas: Canvas, rects: List) { + rects.forEach { rect -> canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, fill) } + } + + private companion object { + /** + * The design system's amber-gold (#E3A64A) at 35% alpha. + * + * Translucent because it is drawn OVER the glyphs: an opaque fill would hide the very text the user + * is about to copy. The literal is inline because `:terminal-view` has no dependency on `:app`'s + * design tokens (dependencies only flow down) — keep it in step with `WebTermColors` by hand. + */ + const val SELECTION_ARGB: Int = 0x59E3A64A.toInt() + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt index 0c77f7c..e0239fa 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt @@ -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 * do not (plan §6.1/§6.5 deviations, both recorded there and on [onLongPress]). * - + * Text selection is the one that changed shape rather than being cut: the long press is still consumed — + * the stock `ActionMode` can never arm — but it now starts the first-party selection layer + * ([TerminalSelectionController]) instead of being discarded. + * * @param sink the single outlet — `:app`'s chord router, the emulator's live cursor modes, and the byte * send pump. See [TerminalInputSink]. */ @@ -126,44 +129,31 @@ internal class WebTermTerminalViewClient(private val sink: TerminalInputSink) : 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 - * one supported way to stop `startTextSelectionMode` from arming. It has to be stopped, because the - * stock selection ActionMode is a crash rather than a feature without a `TerminalSession`: + * **The return value MUST stay true.** `TerminalView$1.onLongPress` offsets 14-30 are + * `if (mClient.onLongPress(e)) return;`, so true is the one supported way to stop + * `startTextSelectionMode` from arming — and it has to be stopped, because the stock selection + * `ActionMode` is a crash rather than a feature without a `TerminalSession`: * `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 (**Copy**) are * `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 (**Paste**) are * `mTermSession.onPasteTextFromClipboard()` — both unguarded, both on the only two buttons the mode * exists for. Selecting text would work; the moment the user tapped Copy the process would die. + * `mTermSession` cannot be supplied either: `TerminalSession` is `final`, its constructor builds a + * process-bound `MainThreadHandler`, and having no local subprocess is the point (plan §6.1). * - * The controller itself is otherwise session-free (it reads `TerminalBuffer.getSelectedText`), so a - * clipboard path is buildable — but it needs its own ActionMode, its own copy action and its own - * selection highlight, i.e. a feature, not a guard. Until then a long press is a no-op instead of a - * loaded gun, and the deviation is reported to the plan owner rather than left silently dead. + * Copy-out is therefore **no longer a feature gap**: the press is spent on the replacement layer + * instead of being discarded. [TerminalSelection] carries the design — our own cell coordinates + * and highlight, stock's session-free `TerminalBuffer.getSelectedText` for the extraction, and a direct + * `ClipboardManager` write ([TerminalClipboard]), with `TextSelectionCursorController` bypassed + * entirely. The affordance is our own floating `ActionMode` ([TerminalSelectionActionMode]), which is + * what plan §6.5 asks for; only the stock controller is replaced. Paste is untouched — it already works + * through the IME ([RemoteTerminalInputConnection]). */ - /** - * Consume the long press, which suppresses stock text selection. - * - * **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 onLongPress(event: MotionEvent?): Boolean { + event?.let { sink.onLongPressAt(it.x, it.y) } + return true + } override fun copyModeChanged(copyMode: Boolean) = Unit diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSelectionTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSelectionTest.kt new file mode 100644 index 0000000..9c25eef --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSelectionTest.kt @@ -0,0 +1,319 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.TerminalEmulator +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_HEIGHT_PX +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_WIDTH_PX +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.down +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.move +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.up + +/** + * Copy-out, driven through the real host frame and the real `TerminalViewClient` — the first-party + * replacement for the stock selection UI that cannot be made reachable (its Copy button dereferences the + * absent `TerminalSession`; see [TerminalSelection]'s doc and [WebTermTerminalViewClient.onLongPress]). + * + * The gesture entry point is called directly because Termux's `GestureAndScaleRecognizer` is framework code, + * stripped by the mockable `android.jar`. Everything after it — the client's decision, the pixel→cell + * mapping, the touch routing, the state machine, the buffer read — is the real compiled path. + */ +class RemoteTerminalSelectionTest { + + private lateinit var host: RemoteTerminalHostView + private lateinit var client: WebTermTerminalViewClient + private lateinit var clipboard: RecordingClipboard + private lateinit var sink: SelectionRoutingSink + + /** The same forwarding [RemoteTerminalView] installs: long press and tap belong to the frame. */ + private class SelectionRoutingSink(private val host: () -> RemoteTerminalHostView) : TerminalInputSink { + val sent = mutableListOf() + var keyboardRequests = 0 + + override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false + override fun cursorModes(): TerminalCursorModes? = null + override fun sendInput(data: String) { + sent += data + } + + override fun onTapped() { + if (host().isSelectingText) host().clearSelection() else keyboardRequests++ + } + + override fun onLongPressAt(xPx: Float, yPx: Float) { + host().beginSelectionAt(xPx, yPx) + } + + override fun onViewSize(widthPx: Int, heightPx: Int) = Unit + } + + private class RecordingClipboard : TerminalClipboard { + val puts = mutableListOf() + + override fun put(text: String): Boolean { + puts += text + return true + } + } + + @BeforeEach + fun setUp() { + StockTerminalViewFixture.mockCellMetrics() + clipboard = RecordingClipboard() + host = RemoteTerminalHostView(StockTerminalViewFixture.context(), clipboard) + sink = SelectionRoutingSink { host } + host.sink = sink + client = WebTermTerminalViewClient(sink) + host.installClient(client) + } + + @AfterEach fun tearDown() = unmockkAll() + + private fun bind(vararg output: String): TerminalEmulator { + val emulator = StockTerminalViewFixture.emulator() + output.forEach { emulator.feed(it) } + host.bindEmulator(emulator) + return emulator + } + + /** Pixel centre of a cell, using the fixture's pinned cell box. */ + private fun x(column: Int) = (column + 0.5f) * CELL_WIDTH_PX + private fun y(row: Int) = (row + 0.5f) * CELL_HEIGHT_PX + + private fun longPress(column: Int, row: Int) { + assertTrue( + client.onLongPress(down(y = y(row), x = x(column))), + "the long press must stay CONSUMED so stock text selection can never arm", + ) + } + + // ── The constraint: the stock selection mode stays unreachable ─────────────────────────────────── + + @Test + fun `a long press starts a first-party selection and never arms the stock ActionMode`() { + bind("cat /etc/hosts") + + longPress(column = 6, row = 0) + + assertTrue(host.isSelectingText, "the first-party layer owns the selection") + assertFalse( + host.stockView.isSelectingText, + "TextSelectionCursorController's Copy is mTermSession.onCopyTextToClipboard — an NPE", + ) + } + + @Test + fun `a long press before an emulator is bound is simply ignored`() { + longPress(column = 3, row = 0) + + assertFalse(host.isSelectingText) + } + + // ── Selecting, extending, copying ─────────────────────────────────────────────────────────────── + + @Test + fun `the long press snaps to the word under the finger and copies exactly that`() { + bind("cat /etc/hosts") + longPress(column = 6, row = 0) + + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + + assertEquals(listOf("/etc/hosts"), clipboard.puts) + } + + @Test + fun `dragging after the long press extends the selection instead of scrolling`() { + // The same vertical drag with no selection is a transcript scroll / arrow-key emit; with a live + // selection it must extend, or the feature is unusable on a touch screen. + bind("alpha\r\nbravo\r\ncharlie\r\n") + longPress(column = 0, row = 0) + + assertTrue(host.onInterceptTouchEvent(move(y = y(2), x = x(2))), "the extend drag is claimed") + host.onTouchEvent(up(y = y(2), x = x(2))) + + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + assertEquals(listOf("alpha\nbravo\ncha"), clipboard.puts) + } + + @Test + fun `an extend drag inside an alternate-screen TUI sends no arrow keys`() { + // Without the selection branch this drag would go to TerminalScrollGesture and type into the TUI. + bind(ENTER_ALTERNATE_BUFFER, "vim buffer") + longPress(column = 0, row = 0) + + host.onInterceptTouchEvent(move(y = y(3), x = x(5))) + host.onTouchEvent(up(y = y(3), x = x(5))) + + assertTrue(sink.sent.isEmpty(), "extending a selection must not reach the shell") + } + + @Test + fun `a drag backwards past the anchor still selects forwards`() { + bind("alpha\r\nbravo\r\ncharlie\r\n") + longPress(column = 2, row = 2) // snaps to "charlie", columns 0-6 + + host.onInterceptTouchEvent(move(y = y(0), x = x(2))) + host.onTouchEvent(up(y = y(0), x = x(2))) + + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + assertEquals(listOf("pha\nbravo\ncharlie"), clipboard.puts) + } + + @Test + fun `a second finger during an extend is not read as an extend`() { + bind("alpha\r\nbravo\r\ncharlie\r\n") + longPress(column = 0, row = 0) + val before = host.copySelection().also { clipboard.puts.clear() } + + host.onInterceptTouchEvent(move(y = y(2), x = x(2), pointers = 2)) + + assertEquals(TerminalCopyResult.COPIED, before) + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + assertEquals(listOf("alpha"), clipboard.puts, "the pinch left the selection alone") + } + + // ── Dismissal ─────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a tap dismisses the selection instead of popping the keyboard`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + client.onSingleTapUp(down(y = y(0), x = x(0))) + + assertFalse(host.isSelectingText) + assertEquals(0, sink.keyboardRequests, "the tap is spent on the dismissal") + } + + @Test + fun `a tap with no selection still raises the keyboard`() { + bind("cat /etc/hosts") + + client.onSingleTapUp(down(y = y(0), x = x(0))) + + assertEquals(1, sink.keyboardRequests) + } + + @Test + fun `after a dismissal a drag scrolls again`() { + val emulator = bind() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + longPress(column = 0, row = 0) + client.onSingleTapUp(down(y = y(0), x = x(0))) + + assertFalse(host.onInterceptTouchEvent(down(START_Y))) + assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX))) + host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + 2 * CELL_HEIGHT_PX)) + + assertEquals(-2, host.stockView.topRow) + } + + @Test + fun `binding a new emulator drops a stale selection`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + bind("a different session") + + assertFalse(host.isSelectingText, "the old span points into a transcript that no longer exists") + } + + @Test + fun `a layout change drops the selection`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + // A new size means a regrid and a reflow: the cells the span names are about to hold other text. + host.onSizeChanged(VIEW_WIDTH_PX, VIEW_HEIGHT_PX, 0, 0) + + assertFalse(host.isSelectingText) + } + + // ── Live output under a selection ─────────────────────────────────────────────────────────────── + + @Test + fun `new output shifts the selection and pins the viewport so the highlight stays on its text`() { + val emulator = bind("target\r\n") + // Overfills the screen on purpose: those rows scroll BEFORE the press, so the emulator's scroll + // counter is already non-zero when the selection is made. Counting them would shift the span by + // rows it never saw — which is why beginSelectionAt zeroes the counter. + repeat(SCREEN_ROWS) { emulator.feed("filler\r\n") } + longPress(column = 1, row = 0) + val selectedBefore = host.selectedText() + + repeat(SCROLL_ROWS) { emulator.feed("more\r\n") } + host.requestScreenUpdate() + + assertEquals(selectedBefore, host.selectedText(), "the same TEXT, at a new row index") + assertEquals( + -SCROLL_ROWS, + host.stockView.topRow, + "stock onScreenUpdated jumps to the newest row unless we pin it back", + ) + } + + @Test + fun `output with no selection still jumps to the newest row`() { + val emulator = bind() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + host.stockView.topRow = -5 + + host.requestScreenUpdate() + + assertEquals(0, host.stockView.topRow, "the pin is for selections only") + } + + @Test + fun `a selection scrolled out of the transcript is dropped`() { + val emulator = StockTerminalViewFixture.emulator(transcriptRows = SMALL_TRANSCRIPT) + host.bindEmulator(emulator) + emulator.feed("target\r\n") + longPress(column = 1, row = 0) + + repeat(SMALL_TRANSCRIPT + SCREEN_ROWS + 1) { emulator.feed("flood\r\n") } + host.requestScreenUpdate() + + assertFalse(host.isSelectingText) + } + + // ── Highlight geometry ────────────────────────────────────────────────────────────────────────── + + @Test + fun `the highlight covers the selected cells`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + val rects = host.highlightRects() + + assertEquals(1, rects.size) + assertEquals(0f, rects[0].left) + assertEquals(3 * CELL_WIDTH_PX, rects[0].right, "'cat' is columns 0-2") + assertEquals(0f, rects[0].top) + assertEquals(CELL_HEIGHT_PX.toFloat(), rects[0].bottom) + } + + @Test + fun `there is no highlight without a selection`() { + bind("cat /etc/hosts") + + assertTrue(host.highlightRects().isEmpty()) + } + + private companion object { + const val SCREEN_ROWS: Int = 24 + const val SCROLLED_OFF_LINES: Int = 40 + const val SCROLL_ROWS: Int = 3 + const val SMALL_TRANSCRIPT: Int = 100 + const val START_Y: Float = 400f + const val CLAIM_TRAVEL_PX: Float = 100f + const val VIEW_WIDTH_PX: Int = 720 + const val VIEW_HEIGHT_PX: Int = 1280 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt index b3ee39c..04f0bc8 100644 --- a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt @@ -120,6 +120,7 @@ internal object StockTerminalViewFixture { internal class RecordingInputSink : TerminalInputSink { val sent = mutableListOf() var taps = 0 + val longPresses = mutableListOf>() val sizes = mutableListOf>() override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false @@ -132,6 +133,10 @@ internal class RecordingInputSink : TerminalInputSink { taps++ } + override fun onLongPressAt(xPx: Float, yPx: Float) { + longPresses += xPx to yPx + } + override fun onViewSize(widthPx: Int, heightPx: Int) { sizes += widthPx to heightPx } diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBufferTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBufferTest.kt new file mode 100644 index 0000000..e1c8eb1 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBufferTest.kt @@ -0,0 +1,186 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.TerminalEmulator +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed + +/** + * Text extraction, against the **real** Termux `TerminalBuffer` driven by real bytes. + * + * This is the part of the stock selection stack that is session-free and therefore reusable: + * `TerminalBuffer.getSelectedText` reads rows and nothing else — no `TerminalSession`, no view. Re-deriving + * its wide-cell walk (`TerminalRow.findStartOfColumn` + `WcWidth`), its per-line trailing-blank trim and its + * soft-wrap joining in Kotlin would be a second implementation of the same subtle logic, guaranteed to + * drift from what the renderer draws. So the extraction is delegated and PINNED here instead: these tests + * assert the behaviour this module depends on, so a Termux bump that changes it fails loudly. + */ +class TerminalSelectionBufferTest { + + private fun bufferOf(emulator: TerminalEmulator?): TerminalSelectionBuffer = + TerminalEmulatorSelectionBuffer { emulator } + + private fun emulatorWith(vararg output: String): TerminalEmulator = + StockTerminalViewFixture.emulator().also { emulator -> output.forEach { emulator.feed(it) } } + + private fun TerminalSelectionBuffer.text(fromColumn: Int, fromRow: Int, toColumn: Int, toRow: Int) = + textIn(TerminalSelectionSpan.spanning(TerminalCell(fromColumn, fromRow), TerminalCell(toColumn, toRow))) + + // ── Bounds ─────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `bounds come from the live emulator and its transcript`() { + val emulator = emulatorWith() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + + val bounds = bufferOf(emulator).bounds() + + assertEquals(TerminalGridBounds(COLUMNS, SCREEN_ROWS, emulator.screen.activeTranscriptRows), bounds) + assertTrue(bounds!!.transcriptRows > 0, "40 lines on a 24-row screen must leave a transcript") + } + + @Test + fun `there are no bounds and no text before an emulator is bound`() { + val buffer = bufferOf(null) + + assertNull(buffer.bounds()) + assertEquals("", buffer.text(0, 0, 5, 0)) + } + + // ── Trailing blanks, wide cells, line joining ──────────────────────────────────────────────────── + + @Test + fun `a row's trailing blanks are trimmed rather than copied as spaces`() { + val buffer = bufferOf(emulatorWith("hi")) + + // Selecting the whole row must not yield "hi" followed by 78 spaces. + assertEquals("hi", buffer.text(0, 0, COLUMNS - 1, 0)) + } + + @Test + fun `a wide CJK cell is copied whole from either of its two columns`() { + // 你 occupies columns 0-1 and 好 columns 2-3; "world" then starts at column 4. + val buffer = bufferOf(emulatorWith("你好world")) + + assertEquals("你", buffer.text(0, 0, 1, 0)) + assertEquals("你", buffer.text(1, 0, 1, 0), "the right half of a wide cell is still that cell") + assertEquals("你好", buffer.text(0, 0, 3, 0)) + assertEquals("好world", buffer.text(2, 0, 8, 0)) + } + + @Test + fun `a soft-wrapped line is joined, so a long command copies as one line`() { + val buffer = bufferOf(emulatorWith("a".repeat(WRAPPED_LINE_LENGTH))) + + val copied = buffer.text(0, 0, WRAPPED_LINE_LENGTH - COLUMNS - 1, 1) + + assertEquals("a".repeat(WRAPPED_LINE_LENGTH), copied) + assertFalse(copied.contains('\n'), "a wrap point is not a line break") + } + + @Test + fun `a real line break is preserved`() { + val buffer = bufferOf(emulatorWith("one\r\ntwo\r\n")) + + assertEquals("one\ntwo", buffer.text(0, 0, 2, 1)) + } + + @Test + fun `a multi-row selection takes the first row to the edge and the last to the focus`() { + val buffer = bufferOf(emulatorWith("alpha\r\nbravo\r\ncharlie\r\n")) + + assertEquals("pha\nbravo\ncha", buffer.text(2, 0, 2, 2)) + } + + @Test + fun `scrollback rows are readable at negative row indices`() { + val emulator = emulatorWith() + repeat(SCROLLED_OFF_LINES) { index -> emulator.feed("line-$index\r\n") } + + // 40 lines plus the trailing empty one scroll 17 rows off a 24-row screen, so screen row 0 holds + // line-17 and transcript row -1 holds the line just above it. + assertEquals("line-${SCROLLED_OFF_LINES - SCREEN_ROWS}", bufferOf(emulator).text(0, -1, COLUMNS - 1, -1)) + } + + // ── The clamp that keeps the two unguarded stock failures unreachable ──────────────────────────── + + @Test + fun `an off-grid span is clamped instead of throwing out of the stock row walk`() { + val buffer = bufferOf(emulatorWith("hello")) + + // Row -9999: TerminalBuffer.externalToInternalRow throws IllegalArgumentException outside + // -activeTranscriptRows..screenRows. Column 9999: TerminalRow.findStartOfColumn walks mText with + // no bound check for column > mColumns. Both are reachable from a touch outside the grid. + // The clamp turns the span into "the whole measured grid", i.e. the one written row plus the blank + // rows below it — the point being that it RETURNS rather than throwing. + assertEquals("hello", buffer.text(-50, -9999, 9999, 9999).trim()) + } + + @Test + fun `an unmeasured emulator yields no text`() { + // A 0-column emulator cannot be constructed by Termux, so the unmeasured case is "no emulator"; + // proving the clamp refuses rather than divides is still worth pinning. + assertNull(TerminalGridBounds(columns = 0, screenRows = 0, transcriptRows = 0).takeIf { it.isMeasured }) + } + + // ── Long-press word snap ──────────────────────────────────────────────────────────────────────── + + @Test + fun `a long press snaps to the run of non-blank cells around it`() { + val buffer = bufferOf(emulatorWith("cat /etc/hosts")) + + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 6, row = 0), buffer) + + assertEquals(TerminalCell(4, 0), selection?.span?.start) + assertEquals(TerminalCell(13, 0), selection?.span?.end) + assertEquals("/etc/hosts", buffer.textIn(selection!!.span)) + } + + @Test + fun `the snap stops at the start of the row`() { + val buffer = bufferOf(emulatorWith("cat /etc/hosts")) + + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 1, row = 0), buffer) + + assertEquals("cat", buffer.textIn(selection!!.span)) + } + + @Test + fun `the snap spans wide cells`() { + val buffer = bufferOf(emulatorWith("你好 world")) + + // Column 1 is the right half of 你; the run is 你好, i.e. columns 0-3. + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 1, row = 0), buffer) + + assertEquals("你好", buffer.textIn(selection!!.span)) + } + + @Test + fun `a long press on blank space selects just that cell`() { + val buffer = bufferOf(emulatorWith("cat /etc/hosts")) + + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 3, row = 0), buffer) + + assertTrue(selection!!.span.isSingleCell, "a blank cell must not swallow the words either side") + assertEquals(TerminalCell(3, 0), selection.span.start) + } + + @Test + fun `there is nothing to snap to before an emulator is bound`() { + assertNull(TerminalSelectionWords.runAt(TerminalCell(0, 0), bufferOf(null))) + } + + private companion object { + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + + /** Enough output to push rows off the top so there is a transcript to read. */ + const val SCROLLED_OFF_LINES: Int = 40 + + /** Longer than one row, so the emulator sets the row's wrap flag. */ + const val WRAPPED_LINE_LENGTH: Int = 100 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionControllerTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionControllerTest.kt new file mode 100644 index 0000000..49ba45e --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionControllerTest.kt @@ -0,0 +1,249 @@ +package wang.yaojia.webterm.terminalview + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The selection state machine, with the buffer and the clipboard faked so the DECISIONS are asserted + * rather than the framework: when a selection exists, what a drag does to it, what a content scroll does + * to it, and — the §8 rule — that terminal text reaches the clipboard on an explicit copy and at no other + * moment. + */ +class TerminalSelectionControllerTest { + + /** A one-row grid of text, enough to drive the state machine; real extraction is pinned elsewhere. */ + private class FakeBuffer( + private val row: String = "hello world", + private val bounds: TerminalGridBounds? = TerminalGridBounds(COLUMNS, SCREEN_ROWS, TRANSCRIPT_ROWS), + ) : TerminalSelectionBuffer { + var reads = 0 + + override fun bounds(): TerminalGridBounds? = bounds + + override fun textIn(span: TerminalSelectionSpan): String { + reads++ + val bounds = bounds ?: return "" + if (span.start.row != span.end.row) return row + val columns = span.columnsIn(span.start.row, bounds.lastColumn) ?: return "" + return row.substring( + columns.first.coerceIn(0, row.length), + (columns.last + 1).coerceIn(0, row.length), + ).trimEnd() + } + } + + private class FakeClipboard(private val accepts: Boolean = true) : TerminalClipboard { + val puts = mutableListOf() + + override fun put(text: String): Boolean { + puts += text + return accepts + } + } + + private class Recorder { + val changes = mutableListOf() + } + + private fun controller( + buffer: TerminalSelectionBuffer = FakeBuffer(), + clipboard: TerminalClipboard = FakeClipboard(), + recorder: Recorder = Recorder(), + ) = TerminalSelectionController(buffer, clipboard) { recorder.changes += it } + + // ── Begin / extend ─────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a long press starts a selection snapped to the word under it`() { + val recorder = Recorder() + val controller = controller(recorder = recorder) + + assertTrue(controller.beginAt(TerminalCell(column = 2, row = 0))) + + assertTrue(controller.isActive) + assertEquals(TerminalCell(0, 0), controller.selection?.span?.start) + assertEquals(TerminalCell(4, 0), controller.selection?.span?.end, "'hello' is columns 0-4") + assertEquals(listOf(true), recorder.changes) + } + + @Test + fun `a drag moves the focus and leaves the anchor where the press landed`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + + controller.extendTo(TerminalCell(column = 8, row = 0)) + + assertEquals(TerminalCell(0, 0), controller.selection?.span?.start, "the anchor survives the drag") + assertEquals(TerminalCell(8, 0), controller.selection?.span?.end) + } + + @Test + fun `dragging back past the anchor flips the span and keeps the snapped word`() { + val controller = controller() + controller.beginAt(TerminalCell(8, 0)) // snaps to "world", columns 6-10 + controller.extendTo(TerminalCell(column = 1, row = 0)) + + val span = controller.selection?.span + + assertEquals(TerminalCell(1, 0), span?.start) + assertEquals( + TerminalCell(10, 0), + span?.end, + "the whole anchored word stays in — a single-cell anchor would clip 'world' to its 'w'", + ) + } + + @Test + fun `a drag off the grid is clamped, not stored raw`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + + controller.extendTo(TerminalCell(column = 9_999, row = 9_999)) + + assertEquals(TerminalCell(COLUMNS - 1, SCREEN_ROWS - 1), controller.selection?.span?.end) + } + + @Test + fun `nothing can be selected before the grid is measured`() { + val controller = controller(buffer = FakeBuffer(bounds = null)) + + assertFalse(controller.beginAt(TerminalCell(2, 0))) + assertFalse(controller.isActive) + } + + @Test + fun `extending without a selection does nothing`() { + val controller = controller() + + controller.extendTo(TerminalCell(4, 0)) + + assertFalse(controller.isActive) + } + + // ── Copy (the only path to the clipboard) ──────────────────────────────────────────────────────── + + @Test + fun `selecting text puts nothing on the clipboard`() { + // Plan §8: terminal content is untrusted and may be a secret. It reaches the clipboard ONLY on an + // explicit user copy — never on selection, never on drag, never automatically. + val clipboard = FakeClipboard() + val controller = controller(clipboard = clipboard) + + controller.beginAt(TerminalCell(2, 0)) + controller.extendTo(TerminalCell(8, 0)) + controller.clear() + + assertTrue(clipboard.puts.isEmpty()) + } + + @Test + fun `an explicit copy puts exactly the selected text on the clipboard once`() { + val clipboard = FakeClipboard() + val controller = controller(clipboard = clipboard) + controller.beginAt(TerminalCell(2, 0)) + + assertEquals(TerminalCopyResult.COPIED, controller.copy()) + + assertEquals(listOf("hello"), clipboard.puts) + } + + @Test + fun `copying with no selection is refused and touches nothing`() { + val clipboard = FakeClipboard() + val controller = controller(clipboard = clipboard) + + assertEquals(TerminalCopyResult.NOTHING_SELECTED, controller.copy()) + assertTrue(clipboard.puts.isEmpty()) + } + + @Test + fun `a blank selection is never written to the clipboard`() { + val clipboard = FakeClipboard() + val controller = controller(buffer = FakeBuffer(row = " "), clipboard = clipboard) + controller.beginAt(TerminalCell(1, 0)) + + assertEquals(TerminalCopyResult.NOTHING_TO_COPY, controller.copy()) + assertTrue(clipboard.puts.isEmpty(), "clearing the user's clipboard with an empty string is data loss") + } + + @Test + fun `a refused clipboard write is reported instead of being reported as success`() { + val controller = controller(clipboard = FakeClipboard(accepts = false)) + controller.beginAt(TerminalCell(2, 0)) + + assertEquals(TerminalCopyResult.CLIPBOARD_UNAVAILABLE, controller.copy()) + } + + // ── Clear ─────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `clearing drops the selection and reports the change once`() { + val recorder = Recorder() + val controller = controller(recorder = recorder) + controller.beginAt(TerminalCell(2, 0)) + + controller.clear() + + assertFalse(controller.isActive) + assertNull(controller.selection) + assertEquals(listOf(true, false), recorder.changes) + } + + @Test + fun `clearing an empty selection reports nothing`() { + // Load-bearing: the copy affordance is dismissed by clear(), and its own dismissal calls clear() + // back. Without this guard that is an infinite loop. + val recorder = Recorder() + val controller = controller(recorder = recorder) + + controller.clear() + controller.clear() + + assertTrue(recorder.changes.isEmpty()) + } + + // ── Content scrolling under a live selection ──────────────────────────────────────────────────── + + @Test + fun `new output shifts the selection so it keeps pointing at the same text`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + + controller.onContentScrolled(scrolledRows = 3) + + assertEquals(TerminalCell(0, -3), controller.selection?.span?.start) + assertEquals(TerminalCell(4, -3), controller.selection?.span?.end) + } + + @Test + fun `a selection scrolled out of the retained transcript is dropped, not left pointing at nothing`() { + val recorder = Recorder() + val controller = controller(recorder = recorder) + controller.beginAt(TerminalCell(2, 0)) + + controller.onContentScrolled(scrolledRows = TRANSCRIPT_ROWS + 1) + + assertFalse(controller.isActive) + assertEquals(listOf(true, false), recorder.changes) + } + + @Test + fun `a scroll of zero rows is not a change`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + val before = controller.selection + + controller.onContentScrolled(scrolledRows = 0) + + assertEquals(before, controller.selection) + } + + private companion object { + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + const val TRANSCRIPT_ROWS: Int = 100 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometryTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometryTest.kt new file mode 100644 index 0000000..4a54352 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometryTest.kt @@ -0,0 +1,117 @@ +package wang.yaojia.webterm.terminalview + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Pixels ↔ cells, and the highlight bands. + * + * The mapping is deliberately the INVERSE of the stock accessors this module already reads + * (`TerminalView.getPointX(col)` = `round(col * fontWidth)`, `getPointY(row)` = `(row - topRow) * + * lineSpacing`), so a tapped cell and its highlight band are the same cell. Stock's own + * `getColumnAndRow` biases the row by `mFontLineSpacingAndAscent` instead, which puts its hit test + * ~20% of a row above the glyph it is drawing — not something to reproduce. + */ +class TerminalSelectionGeometryTest { + + @Test + fun `a touch resolves to the cell under it`() { + val cell = TerminalSelectionGeometry.cellAt( + xPx = 2.5f * CELL_WIDTH_PX, + yPx = 3.5f * CELL_HEIGHT_PX, + metrics = METRICS, + topRow = 0, + ) + + assertEquals(TerminalCell(column = 2, row = 3), cell) + } + + @Test + fun `a touch while scrolled back resolves into the transcript`() { + val cell = TerminalSelectionGeometry.cellAt( + xPx = 0f, + yPx = 0.5f * CELL_HEIGHT_PX, + metrics = METRICS, + topRow = -7, + ) + + assertEquals(TerminalCell(column = 0, row = -7), cell, "row 0 of the viewport is topRow") + } + + @Test + fun `an unmeasured cell box resolves to no cell instead of dividing by zero`() { + assertNull( + TerminalSelectionGeometry.cellAt(10f, 10f, TerminalCellMetrics(0f, 0), topRow = 0), + ) + assertNull( + TerminalSelectionGeometry.cellAt(10f, 10f, TerminalCellMetrics(CELL_WIDTH_PX, 0), topRow = 0), + ) + } + + @Test + fun `a one-row selection is one band, sized to the selected columns`() { + val span = TerminalSelectionSpan.spanning(TerminalCell(2, 1), TerminalCell(4, 1)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0) + + assertEquals( + listOf( + TerminalSelectionRect( + left = 2 * CELL_WIDTH_PX, + top = 1f * CELL_HEIGHT_PX, + right = 5 * CELL_WIDTH_PX, + bottom = 2f * CELL_HEIGHT_PX, + ), + ), + rects, + ) + } + + @Test + fun `a multi-row selection bands the first row to the right edge and the last from the left`() { + val span = TerminalSelectionSpan.spanning(TerminalCell(3, 0), TerminalCell(1, 2)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0) + + assertEquals(3, rects.size) + assertEquals(3 * CELL_WIDTH_PX, rects[0].left) + assertEquals(COLUMNS * CELL_WIDTH_PX, rects[0].right, "the first row runs to the grid edge") + assertEquals(0f, rects[1].left) + assertEquals(COLUMNS * CELL_WIDTH_PX, rects[1].right, "a middle row is fully highlighted") + assertEquals(0f, rects[2].left) + assertEquals(2 * CELL_WIDTH_PX, rects[2].right) + } + + @Test + fun `rows scrolled out of the viewport produce no bands`() { + // A selection made in scrollback, then scrolled away: nothing to paint, and nothing that would + // paint at a negative y over the live screen. + val span = TerminalSelectionSpan.spanning(TerminalCell(0, -40), TerminalCell(5, -38)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0) + + assertTrue(rects.isEmpty()) + } + + @Test + fun `a selection straddling the top of the viewport is clipped to the visible rows`() { + val span = TerminalSelectionSpan.spanning(TerminalCell(0, -3), TerminalCell(5, 1)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = -1) + + assertEquals(3, rects.size, "rows -1, 0 and 1 are visible; -3 and -2 are above the viewport") + assertEquals(0f, rects[0].top, "the first visible band starts at the top of the view") + } + + private companion object { + const val CELL_WIDTH_PX: Float = 12f + const val CELL_HEIGHT_PX: Int = 24 + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + + val METRICS = TerminalCellMetrics(CELL_WIDTH_PX, CELL_HEIGHT_PX) + val BOUNDS = TerminalGridBounds(COLUMNS, SCREEN_ROWS, transcriptRows = 100) + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModelTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModelTest.kt new file mode 100644 index 0000000..293ead9 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModelTest.kt @@ -0,0 +1,158 @@ +package wang.yaojia.webterm.terminalview + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The selection model on its own: cell coordinates, normalisation, clamping and the per-row column + * ranges. No view, no emulator, no android — this is the part that has to be right before any pixel or + * any clipboard write can be. + */ +class TerminalSelectionModelTest { + + // ── Normalisation ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a backwards drag is the same span as the forwards drag`() { + val forwards = selection(cell(2, 1), cell(7, 4)) + val backwards = selection(cell(7, 4), cell(2, 1)) + + assertEquals(forwards.span, backwards.span) + assertEquals(cell(2, 1), forwards.span.start) + assertEquals(cell(7, 4), forwards.span.end) + } + + @Test + fun `a backwards drag inside one row swaps the columns only`() { + val span = selection(cell(9, 3), cell(4, 3)).span + + assertEquals(cell(4, 3), span.start) + assertEquals(cell(9, 3), span.end) + } + + @Test + fun `ordering is row-major, so a lower row wins even with a bigger column`() { + // Dragging up-and-right must not be read as a forwards selection: the row decides. + val span = selection(cell(0, 5), cell(70, 2)).span + + assertEquals(cell(70, 2), span.start) + assertEquals(cell(0, 5), span.end) + } + + @Test + fun `a one-cell selection is a one-cell span`() { + val span = selection(cell(5, 5), cell(5, 5)).span + + assertEquals(span.start, span.end) + assertTrue(span.isSingleCell) + } + + // ── Per-row column ranges (what both the highlight and the extraction use) ─────────────────────── + + @Test + fun `the first row runs to the last column and the last row starts at column zero`() { + val span = selection(cell(3, 0), cell(6, 2)).span + + assertEquals(3..LAST_COLUMN, span.columnsIn(0, LAST_COLUMN)) + assertEquals(0..LAST_COLUMN, span.columnsIn(1, LAST_COLUMN), "a middle row is fully selected") + assertEquals(0..6, span.columnsIn(2, LAST_COLUMN)) + } + + @Test + fun `a single-row span uses both of its columns`() { + val span = selection(cell(3, 4), cell(6, 4)).span + + assertEquals(3..6, span.columnsIn(4, LAST_COLUMN)) + } + + @Test + fun `rows outside the span have no column range`() { + val span = selection(cell(3, 2), cell(6, 4)).span + + assertNull(span.columnsIn(1, LAST_COLUMN)) + assertNull(span.columnsIn(5, LAST_COLUMN)) + } + + // ── Clamping (the reason an off-grid touch cannot crash the buffer read) ───────────────────────── + + @Test + fun `an off-grid touch clamps into the grid`() { + // TerminalRow.findStartOfColumn walks mText with no bound check for column > mColumns, and + // TerminalBuffer.externalToInternalRow THROWS for a row outside the transcript. Clamping here is + // what keeps both unreachable. + val span = selection(cell(-4, -900), cell(500, 99)).span.clampedTo(BOUNDS) + + assertEquals(cell(0, -TRANSCRIPT_ROWS), span?.start) + assertEquals(cell(LAST_COLUMN, SCREEN_ROWS - 1), span?.end) + } + + @Test + fun `an in-grid span is left alone by the clamp`() { + val span = selection(cell(1, -2), cell(9, 3)).span + + assertEquals(span, span.clampedTo(BOUNDS)) + } + + @Test + fun `an unmeasured grid clamps to nothing rather than to cell zero`() { + val span = selection(cell(1, 1), cell(2, 2)).span + + assertNull(span.clampedTo(TerminalGridBounds(columns = 0, screenRows = 0, transcriptRows = 0))) + } + + // ── The anchor is a SPAN, because a long press snaps to a word ─────────────────────────────────── + + @Test + fun `a word-anchored selection starts as exactly that word`() { + val selection = TerminalSelection.anchoredOn(cell(4, 1), cell(13, 1)) + + assertEquals(cell(4, 1), selection.span.start) + assertEquals(cell(13, 1), selection.span.end) + assertEquals(cell(13, 1), selection.focus, "the focus starts at the end the drag continues from") + } + + @Test + fun `dragging back past a word-anchored selection keeps the whole word`() { + // The editor behaviour: the word you long-pressed stays selected however you drag. Anchoring on a + // single cell instead would collapse "charlie" to its first character the moment the finger moved + // above it. + val dragged = TerminalSelection.anchoredOn(cell(0, 2), cell(6, 2)).withFocus(cell(2, 0)) + + assertEquals(cell(2, 0), dragged.span.start) + assertEquals(cell(6, 2), dragged.span.end) + } + + @Test + fun `a focus inside the anchor changes nothing`() { + val anchored = TerminalSelection.anchoredOn(cell(4, 1), cell(13, 1)) + + assertEquals(anchored.span, anchored.withFocus(cell(8, 1)).span) + } + + // ── Content scrolling ─────────────────────────────────────────────────────────────────────────── + + @Test + fun `new output shifts the selection up by the scrolled rows`() { + // External row indices move when the screen scrolls: the content the user selected is one row + // higher after one new line. Without this the highlight (and the copy) would silently drift. + val moved = selection(cell(2, 5), cell(4, 6)).movedBy(-3) + + assertEquals(cell(2, 2), moved.span.start) + assertEquals(cell(4, 3), moved.span.end) + } + + private companion object { + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + const val TRANSCRIPT_ROWS: Int = 100 + const val LAST_COLUMN: Int = COLUMNS - 1 + val BOUNDS = TerminalGridBounds(COLUMNS, SCREEN_ROWS, TRANSCRIPT_ROWS) + + fun cell(column: Int, row: Int) = TerminalCell(column, row) + + /** A press at [from] with the finger now at [to] — the one-cell-anchor case. */ + fun selection(from: TerminalCell, to: TerminalCell) = TerminalSelection.at(from).withFocus(to) + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt index 29050fc..9c1052c 100644 --- a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt @@ -1,6 +1,9 @@ package wang.yaojia.webterm.terminalview import android.view.KeyEvent +import android.view.MotionEvent +import io.mockk.every +import io.mockk.mockk import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -26,6 +29,7 @@ class WebTermTerminalViewClientTest { var taps = 0 var sizes = mutableListOf>() var keysOfferedToApp = 0 + val longPresses = mutableListOf>() fun setCursorApplicationMode(on: Boolean) { modes = TerminalCursorModes(cursorKeysApplication = on, keypadApplication = false) @@ -45,6 +49,10 @@ class WebTermTerminalViewClientTest { taps++ } + override fun onLongPressAt(xPx: Float, yPx: Float) { + longPresses += xPx to yPx + } + override fun onViewSize(widthPx: Int, heightPx: Int) { 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(relaxed = true) + every { event.x } returns PRESS_X_PX + every { event.y } returns PRESS_Y_PX + + assertTrue(WebTermTerminalViewClient(sink).onLongPress(event)) + + assertEquals(listOf(PRESS_X_PX to PRESS_Y_PX), sink.longPresses) + } + @Test fun `a tap raises the keyboard and pinch-zoom is declined`() { val sink = FakeSink() @@ -170,4 +192,9 @@ class WebTermTerminalViewClientTest { assertFalse(client.readShiftKey()) assertFalse(client.readFnKey()) } + + private companion object { + const val PRESS_X_PX: Float = 42f + const val PRESS_Y_PX: Float = 96f + } }