diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 350ade0..7e3733d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -12,13 +12,32 @@
+
+
+ android:usesCleartextTraffic="true">
Unit) {
}
}
+// QrCodeAnalyzer is @ExperimentalGetImage (it reads ImageProxy.image to feed ML Kit); constructing it
+// here propagates that requirement to this call site, so the opt-in has to be declared here too.
+@androidx.annotation.OptIn(ExperimentalGetImage::class)
@Composable
private fun CameraPreview(onScanned: (String) -> Unit) {
val context = LocalContext.current
diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
index 72871e6..cb7e232 100644
--- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
+++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
@@ -315,7 +315,11 @@ internal object PairingCopy {
"这个端口在运行别的服务,不是 web-terminal。端口对吗?"
is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived
is PairingError.CleartextBlocked ->
- "系统阻止了到 ${error.host} 的明文连接。仅局域网 / Tailscale 地址允许 ws:// 明文。"
+ // Inverted by the A19 cleartext fix: LAN cleartext is now PERMITTED (the platform has no
+ // CIDR syntax, so it could not be scoped to RFC1918 — see network_security_config.xml).
+ // The only host that can still produce this error is the tunnel domain, which
+ // network_security_config pins to TLS because it always serves a real LE cert.
+ "${error.host} 必须使用 https / wss —— 该主机由设备证书保护,不允许降级为明文。"
PairingError.TlsFailure ->
// Tunnel hosts present a real LE server cert, so a TLS failure there means OUR client cert
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
index a58ffec..77738ff 100644
--- a/android/app/src/main/res/xml/network_security_config.xml
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -1,10 +1,84 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+ terminal.yaojia.wang
+
+
diff --git a/android/app/src/test/java/wang/yaojia/webterm/NetworkSecurityConfigTest.kt b/android/app/src/test/java/wang/yaojia/webterm/NetworkSecurityConfigTest.kt
new file mode 100644
index 0000000..f783575
--- /dev/null
+++ b/android/app/src/test/java/wang/yaojia/webterm/NetworkSecurityConfigTest.kt
@@ -0,0 +1,112 @@
+package wang.yaojia.webterm
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import org.w3c.dom.Element
+import org.w3c.dom.Document
+import java.io.File
+import javax.xml.parsers.DocumentBuilderFactory
+
+/**
+ * Regression lock for the A19 cleartext posture (BLOCKER 3).
+ *
+ * The shipped `network_security_config.xml` used to be a STUB that denied cleartext globally, which made
+ * every bare-LAN `ws://` connect impossible on a real device — while `HostEndpoint.fromBaseUrl` accepts
+ * `http` and `PairingViewModel` treats RFC1918 as a first-class tier. Bare LAN is this product's PRIMARY
+ * use case (CLAUDE.md "What This Is"), so that contradiction made the app tunnel-only.
+ *
+ * These are resource-shape assertions, deliberately NOT a behavioural test: the behaviour they guard
+ * (does a socket to 192.168.x.x open?) is only observable on a device and lives in DEVICE_QA_CHECKLIST.
+ * What CAN regress silently in a code review is the XML, so that is what is pinned here. Each assertion
+ * corresponds to a way the fix could be undone without any other test going red.
+ */
+@DisplayName("network_security_config.xml — A19 cleartext posture")
+class NetworkSecurityConfigTest {
+
+ private fun parse(path: String): Document =
+ DocumentBuilderFactory.newInstance()
+ .apply { isNamespaceAware = true }
+ .newDocumentBuilder()
+ .parse(File(path))
+
+ private fun nsc(): Document = parse("src/main/res/xml/network_security_config.xml")
+
+ private fun elements(doc: Document, tag: String): List {
+ val nodes = doc.getElementsByTagName(tag)
+ return (0 until nodes.length).map { nodes.item(it) as Element }
+ }
+
+ @Test
+ @DisplayName("base-config permits cleartext, so a user-typed LAN address can still connect")
+ fun baseConfigPermitsCleartext() {
+ val base = elements(nsc(), "base-config").single()
+ assertEquals(
+ "true",
+ base.getAttribute("cleartextTrafficPermitted"),
+ "Denying cleartext in base-config makes every ws:// LAN connect fail with " +
+ "UnknownServiceException. The platform has no CIDR syntax, so this CANNOT be " +
+ "narrowed to RFC1918 — see the header comment in the resource.",
+ )
+ }
+
+ @Test
+ @DisplayName("the tunnel domain keeps its cleartext block, so it can never be downgraded")
+ fun tunnelDomainDeniesCleartext() {
+ val denying = elements(nsc(), "domain-config")
+ .filter { it.getAttribute("cleartextTrafficPermitted") == "false" }
+ assertTrue(denying.isNotEmpty(), "the tunnel domain-config that pins TLS was removed")
+
+ val tunnelDomain = denying
+ .flatMap { cfg ->
+ val kids = cfg.getElementsByTagName("domain")
+ (0 until kids.length).map { kids.item(it) as Element }
+ }
+ .singleOrNull { it.textContent.trim() == "terminal.yaojia.wang" }
+
+ assertTrue(
+ tunnelDomain != null,
+ "terminal.yaojia.wang must stay under a cleartextTrafficPermitted=false domain-config: " +
+ "it always serves a real LE cert and is mTLS-gated, so a plaintext dial there can only " +
+ "be a downgrade or a typo.",
+ )
+ assertEquals(
+ "true",
+ tunnelDomain!!.getAttribute("includeSubdomains"),
+ "per-host tunnel subdomains (e.g. h7fd8.terminal.yaojia.wang) must be covered too",
+ )
+ }
+
+ @Test
+ @DisplayName("no user-installed CA can ever be trusted")
+ fun trustAnchorsAreSystemOnly() {
+ val doc = nsc()
+ assertTrue(
+ elements(doc, "debug-overrides").isEmpty(),
+ "a trust-anchor block would let an added device CA MITM a build",
+ )
+ val certs = elements(doc, "certificates")
+ assertTrue(certs.isNotEmpty(), "trust anchors must stay declared, not inherited")
+ certs.forEach {
+ assertEquals(
+ "system",
+ it.getAttribute("src"),
+ "only system CAs may be trusted (plan §6.9); 'user' would trust an added device CA",
+ )
+ }
+ }
+
+ @Test
+ @DisplayName("the manifest attribute agrees with the config — they can never drift apart")
+ fun manifestAgreesWithConfig() {
+ val app = elements(parse("src/main/AndroidManifest.xml"), "application").single()
+ assertEquals(
+ "true",
+ app.getAttributeNS("http://schemas.android.com/apk/res/android", "usesCleartextTraffic"),
+ "the manifest must not state the opposite of what network_security_config ships. The " +
+ "config wins at runtime on minSdk 29, so a stale 'false' here is a lie to the reader, " +
+ "not a second layer of defence.",
+ )
+ }
+}
diff --git a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipherTest.kt b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipherTest.kt
new file mode 100644
index 0000000..d1719d5
--- /dev/null
+++ b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipherTest.kt
@@ -0,0 +1,129 @@
+package wang.yaojia.webterm.tlsandroid
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.security.GeneralSecurityException
+import java.util.UUID
+
+/**
+ * Instrumented tests for [TinkAuthCookieCipher] — the at-rest encryption `:host-registry` seals the
+ * `webterm_auth` shell credential with. Real `AndroidKeyStore` + real Tink, NOT Robolectric (plan §7),
+ * so these COMPILE in the build env and RUN during device QA.
+ *
+ * Each test uses a fresh keyset/pref/master-key namespace so a leftover key from an earlier run can
+ * never make a test pass or fail spuriously.
+ */
+@RunWith(AndroidJUnit4::class)
+class TinkAuthCookieCipherTest {
+
+ private lateinit var context: Context
+ private lateinit var namespace: String
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ namespace = UUID.randomUUID().toString().take(8)
+ }
+
+ private fun newCipher(suffix: String = "a") = TinkAuthCookieCipher(
+ context = context,
+ keysetName = "test_cookie_keyset_${namespace}_$suffix",
+ prefFileName = "test_cookie_prefs_${namespace}_$suffix",
+ masterKeyUri = "android-keystore://test_cookie_master_${namespace}_$suffix",
+ )
+
+ @Test
+ fun sealed_text_round_trips_and_hides_the_plaintext() {
+ // Arrange
+ val cipher = newCipher()
+
+ // Act
+ val sealed = cipher.seal(PLAINTEXT)
+
+ // Assert: real encryption, not an encoding — and it opens back exactly.
+ assertNotEquals(PLAINTEXT, sealed)
+ assertFalse("the plaintext survived into the ciphertext", sealed.contains(SECRET))
+ assertEquals(PLAINTEXT, cipher.open(sealed))
+ }
+
+ @Test
+ fun a_new_cipher_over_the_same_keyset_opens_what_the_previous_one_sealed() {
+ // Arrange: the cold-start path — process restart, same AndroidKeyStore master key.
+ val sealed = newCipher().seal(PLAINTEXT)
+
+ // Act + Assert
+ assertEquals(PLAINTEXT, newCipher().open(sealed))
+ }
+
+ @Test
+ fun sealing_the_same_text_twice_yields_different_ciphertext() {
+ // AEAD is randomised (fresh IV), so equal cookies do not produce an equal blob — otherwise an
+ // observer of the file could tell "the credential did not change" across writes.
+ val cipher = newCipher()
+
+ assertNotEquals(cipher.seal(PLAINTEXT), cipher.seal(PLAINTEXT))
+ }
+
+ @Test
+ fun a_tampered_blob_is_rejected_rather_than_half_decrypted() {
+ // Arrange
+ val cipher = newCipher()
+ val sealed = cipher.seal(PLAINTEXT)
+ val flipped = sealed.mutateOneChar()
+
+ // Act + Assert: authenticated encryption → throws, never returns attacker-shaped plaintext.
+ assertThrows(GeneralSecurityException::class.java) { cipher.open(flipped) }
+ }
+
+ @Test
+ fun a_blob_that_is_not_base64_is_rejected_without_echoing_it() {
+ val cipher = newCipher()
+
+ val error = assertThrows(GeneralSecurityException::class.java) { cipher.open("!!! not base64 !!!") }
+
+ assertFalse(
+ "an error message must never echo the stored blob",
+ error.message.orEmpty().contains("not base64 !!!"),
+ )
+ }
+
+ @Test
+ fun another_namespaces_key_cannot_open_this_blob() {
+ // Arrange: two ciphers = two keysets under two master keys (the cert store is a third).
+ val sealed = newCipher("a").seal(PLAINTEXT)
+
+ // Act + Assert: key separation is real, so one at-rest namespace cannot read another's.
+ assertThrows(GeneralSecurityException::class.java) { newCipher("b").open(sealed) }
+ }
+
+ @Test
+ fun toString_never_reveals_the_credential() {
+ val cipher = newCipher()
+ cipher.seal(PLAINTEXT)
+
+ assertFalse(cipher.toString().contains(SECRET))
+ }
+
+ /** Flip one character of the base64 body so the AEAD tag no longer matches. */
+ private fun String.mutateOneChar(): String {
+ val index = length / 2
+ val replacement = if (this[index] == 'A') 'B' else 'A'
+ return substring(0, index) + replacement + substring(index + 1)
+ }
+
+ private companion object {
+ const val SECRET = "SUPER_SECRET_SHELL_TOKEN_9f3a7c11"
+
+ /** Shaped like what the store actually seals: the serialized cookie list. */
+ const val PLAINTEXT =
+ """[{"hostKey":"http://10.0.0.1:3000","name":"webterm_auth","value":"$SECRET"}]"""
+ }
+}
diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt
index d027574..0926361 100644
--- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt
+++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt
@@ -4,10 +4,6 @@ import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import com.google.crypto.tink.Aead
-import com.google.crypto.tink.KeyTemplates
-import com.google.crypto.tink.RegistryConfiguration
-import com.google.crypto.tink.aead.AeadConfig
-import com.google.crypto.tink.integration.android.AndroidKeysetManager
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
@@ -118,16 +114,8 @@ public class TinkEnrollmentRecordStore(
if (!committed) throw java.io.IOException("Failed to durably clear the device enrollment record")
}
- private fun buildAead(): Aead {
- AeadConfig.register()
- val keysetHandle = AndroidKeysetManager.Builder()
- .withSharedPref(appContext, keysetName, prefFileName)
- .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
- .withMasterKeyUri(masterKeyUri)
- .build()
- .keysetHandle
- return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
- }
+ private fun buildAead(): Aead =
+ buildAndroidKeystoreAead(appContext, keysetName, prefFileName, masterKeyUri)
private fun prefs(): SharedPreferences =
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
@@ -136,7 +124,6 @@ public class TinkEnrollmentRecordStore(
private const val DEFAULT_KEYSET_NAME = "webterm_enroll_keyset"
private const val DEFAULT_PREF_FILE = "webterm_enroll_record_prefs"
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_enroll_master_key"
- private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
private const val BLOB_KEY = "enrollment_record_blob"
private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.enrollment-record".toByteArray()
}
diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAead.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAead.kt
new file mode 100644
index 0000000..ae015ce
--- /dev/null
+++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAead.kt
@@ -0,0 +1,43 @@
+package wang.yaojia.webterm.tlsandroid
+
+import android.content.Context
+import com.google.crypto.tink.Aead
+import com.google.crypto.tink.KeyTemplates
+import com.google.crypto.tink.RegistryConfiguration
+import com.google.crypto.tink.aead.AeadConfig
+import com.google.crypto.tink.integration.android.AndroidKeysetManager
+
+/** The one AEAD primitive every at-rest blob in this module is encrypted with (plan §2/§8). */
+private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
+
+/**
+ * Builds the Tink [Aead] for one at-rest namespace: the keyset lives in the app-private
+ * `SharedPreferences` file [prefFileName] under [keysetName] and is itself encrypted by the
+ * `AndroidKeyStore` master key [masterKeyUri], so the on-disk keyset is useless off this device.
+ *
+ * Shared by every store here ([TinkCertStore], [TinkEnrollmentRecordStore], [TinkAuthCookieCipher])
+ * so the custody model is defined ONCE — a weaker key template or a forgotten master-key wrapping
+ * cannot drift into one store while the others stay hardened. Each caller passes its OWN
+ * keyset/file/master-key names: same mechanism, separate keys, so one namespace's blob can never be
+ * opened with another's key.
+ *
+ * Not cached — callers hold the result in a `by lazy`, which keeps the (synchronous, disk- and
+ * keystore-touching) build off the first-use path's critical section and off `Dispatchers.Main`.
+ *
+ * @throws java.security.GeneralSecurityException if the keyset cannot be created or unwrapped.
+ */
+internal fun buildAndroidKeystoreAead(
+ appContext: Context,
+ keysetName: String,
+ prefFileName: String,
+ masterKeyUri: String,
+): Aead {
+ AeadConfig.register()
+ val keysetHandle = AndroidKeysetManager.Builder()
+ .withSharedPref(appContext, keysetName, prefFileName)
+ .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
+ .withMasterKeyUri(masterKeyUri)
+ .build()
+ .keysetHandle
+ return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
+}
diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipher.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipher.kt
new file mode 100644
index 0000000..c735001
--- /dev/null
+++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipher.kt
@@ -0,0 +1,97 @@
+package wang.yaojia.webterm.tlsandroid
+
+import android.content.Context
+import android.util.Base64
+import com.google.crypto.tink.Aead
+import java.security.GeneralSecurityException
+
+/**
+ * At-rest encryption for the persisted `webterm_auth` session cookie — the same custody model as
+ * [TinkCertStore] (Tink AEAD under an `AndroidKeyStore`-wrapped master key), applied to the credential
+ * that is strictly MORE powerful than the cert chain: it grants a full shell for the server's 30-day
+ * `Max-Age` (`src/http/auth.ts`), so plan §8's "encrypted, app-private, never in a backup" rule binds
+ * here at least as hard.
+ *
+ * ## Why this class does not implement `:host-registry`'s `AuthCookieCipher`
+ * It matches that interface's shape EXACTLY ([seal] / [open]) but does not declare it: this module
+ * must not depend on `:host-registry` (siblings under `:app` — `android/README.md`: *dependencies only
+ * flow down; nothing points upward*), and `:host-registry` must not depend on Tink. `:app` owns the
+ * 4-line adapter, which is the same bridge pattern A15 already uses for `:transport-okhttp`'s
+ * `ClientIdentityProvider`:
+ *
+ * ```kotlin
+ * val tink = TinkAuthCookieCipher(context)
+ * val cipher = object : AuthCookieCipher {
+ * override fun seal(plaintext: String): String = tink.seal(plaintext)
+ * override fun open(sealed: String): String = tink.open(sealed)
+ * }
+ * ```
+ *
+ * ## Custody
+ * - Own keyset, own pref file, own master key and own AEAD associated data — a cookie blob can never
+ * be opened with the cert store's key, nor swapped into its slot (and vice versa).
+ * - The master key never leaves `AndroidKeyStore`, so the ciphertext is useless off this device even
+ * if the app-private file is copied out.
+ * - Nothing here is ever logged and [toString] cannot reveal the plaintext; a decrypt failure carries
+ * only the reason, never the payload (`AuthCookieCipher`'s contract).
+ *
+ * Both operations throw on failure — the caller (`DataStoreAuthCookieStore`) then fails CLOSED,
+ * persisting nothing rather than falling back to plaintext.
+ *
+ * @param context any Context; only `applicationContext` is retained.
+ */
+public class TinkAuthCookieCipher(
+ context: Context,
+ private val keysetName: String = DEFAULT_KEYSET_NAME,
+ private val prefFileName: String = DEFAULT_PREF_FILE,
+ private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
+) {
+ private val appContext: Context = context.applicationContext
+
+ // Built on first use: registers AEAD, loads-or-creates the AndroidKeystore-wrapped keyset.
+ private val aead: Aead by lazy {
+ buildAndroidKeystoreAead(appContext, keysetName, prefFileName, masterKeyUri)
+ }
+
+ /**
+ * Encrypt [plaintext] into a Base64 blob safe to store in (plaintext) Preferences DataStore.
+ *
+ * @throws GeneralSecurityException if the keyset/master key is unavailable — the caller then
+ * persists NOTHING (fail closed).
+ */
+ public fun seal(plaintext: String): String {
+ val ciphertext = aead.encrypt(plaintext.toByteArray(Charsets.UTF_8), ASSOCIATED_DATA)
+ return Base64.encodeToString(ciphertext, Base64.NO_WRAP)
+ }
+
+ /**
+ * Decrypt what [seal] produced. AEAD, so a tampered, truncated or foreign blob throws instead of
+ * yielding attacker-shaped bytes.
+ *
+ * @throws GeneralSecurityException if [sealed] is not an intact blob this device's key can open.
+ */
+ public fun open(sealed: String): String {
+ val ciphertext = try {
+ Base64.decode(sealed, Base64.NO_WRAP)
+ } catch (e: IllegalArgumentException) {
+ // Deliberately does not echo `sealed` — it is the credential's ciphertext.
+ throw GeneralSecurityException("Sealed auth-cookie blob was not valid base64", e)
+ }
+ return String(aead.decrypt(ciphertext, ASSOCIATED_DATA), Charsets.UTF_8)
+ }
+
+ /** Redacted by construction: this object's whole purpose is a credential. */
+ override fun toString(): String = "TinkAuthCookieCipher(keyset=$keysetName)"
+
+ public companion object {
+ private const val DEFAULT_KEYSET_NAME = "webterm_auth_cookie_keyset"
+ private const val DEFAULT_PREF_FILE = "webterm_auth_cookie_prefs"
+ private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_auth_cookie_master_key"
+
+ /**
+ * AEAD associated data — binds the ciphertext to the auth-cookie context, so a blob lifted
+ * from (or into) another Tink slot in this app fails to decrypt. Not secret.
+ */
+ private val ASSOCIATED_DATA: ByteArray = "webterm.host-registry.auth-cookie".toByteArray()
+ }
+}
diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt
index 5e8e333..ff15458 100644
--- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt
+++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt
@@ -4,10 +4,6 @@ import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import com.google.crypto.tink.Aead
-import com.google.crypto.tink.KeyTemplates
-import com.google.crypto.tink.RegistryConfiguration
-import com.google.crypto.tink.aead.AeadConfig
-import com.google.crypto.tink.integration.android.AndroidKeysetManager
/**
* At-rest storage for the encrypted device-identity "live pointer" ([StoredIdentityMetadata]).
@@ -37,9 +33,10 @@ public interface CertStore {
* - NOT stored: the private key (non-exportable in AndroidKeyStore — the one key home), the raw
* `.p12` bytes, and the passphrase.
*
- * The keyset + ciphertext live in an app-private `SharedPreferences` file (uninstall-wiped, excluded
- * from cloud backup by the app manifest); the Tink keyset itself is encrypted by the AndroidKeystore
- * master key so the on-disk keyset is useless off this device.
+ * The keyset + ciphertext live in an app-private `SharedPreferences` file (uninstall-wiped, and kept out
+ * of Android Auto Backup by `android:allowBackup="false"` in the app manifest — verify that attribute
+ * before trusting this sentence); the Tink keyset itself is encrypted by the AndroidKeystore master key,
+ * so even a copied-out file is useless off this device.
*/
public class TinkCertStore(
context: Context,
@@ -105,16 +102,8 @@ public class TinkCertStore(
}
}
- private fun buildAead(): Aead {
- AeadConfig.register()
- val keysetHandle = AndroidKeysetManager.Builder()
- .withSharedPref(appContext, keysetName, prefFileName)
- .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
- .withMasterKeyUri(masterKeyUri)
- .build()
- .keysetHandle
- return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
- }
+ private fun buildAead(): Aead =
+ buildAndroidKeystoreAead(appContext, keysetName, prefFileName, masterKeyUri)
private fun prefs(): SharedPreferences =
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
@@ -123,7 +112,6 @@ public class TinkCertStore(
private const val DEFAULT_KEYSET_NAME = "webterm_cert_keyset"
private const val DEFAULT_PREF_FILE = "webterm_client_tls_prefs"
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_cert_master_key"
- private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
private const val BLOB_KEY = "cert_chain_blob"
/**
diff --git a/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreTest.kt b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreTest.kt
new file mode 100644
index 0000000..5cc9063
--- /dev/null
+++ b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreTest.kt
@@ -0,0 +1,217 @@
+package wang.yaojia.webterm.hostregistry
+
+import android.content.Context
+import android.util.Base64
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStoreFile
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.util.UUID
+
+/**
+ * Instrumented (device) contract tests for [DataStoreAuthCookieStore] — the real
+ * Preferences-DataStore-backed store needs a Context, so these run on a device (no emulator in the
+ * build env → compiled here, executed in device QA, plan §7). They mirror the JVM
+ * [InMemoryAuthCookieStoreTest] contract and add the storage-only properties: the blob survives a
+ * genuine cold start, a record that expired while at rest is never handed back, and — the security
+ * one — the credential never appears in the DataStore FILE on disk, sealed or fail-closed.
+ *
+ * The at-rest encryption is driven through a reversible test cipher: this module has no Tink
+ * dependency by design (see [AuthCookieCipher]). The real `AndroidKeyStore`-backed cipher is tested
+ * instrumented in `:client-tls-android` (`TinkAuthCookieCipherTest`).
+ *
+ * ⚠ Every DataStore is created inside [onFile], which CANCELS the store's scope on the way out.
+ * `datastore-core` throws `IllegalStateException("There are multiple DataStores active for the same
+ * file")` if a second instance touches a file the first still holds, so a cold-start test MUST release
+ * the first one before opening the second.
+ */
+@RunWith(AndroidJUnit4::class)
+class DataStoreAuthCookieStoreTest {
+
+ private val hostA = "http://10.0.0.1:3000"
+ private val hostB = "https://tunnel.example:443"
+
+ private fun context(): Context = ApplicationProvider.getApplicationContext()
+
+ private fun fileFor(name: String): File = context().preferencesDataStoreFile(name)
+
+ /**
+ * Opens a DataStore over [name], runs [block] against a store wired to [cipher] and [now], then
+ * cancels the DataStore's scope so the file is released for the next cold start.
+ */
+ private fun onFile(
+ name: String,
+ cipher: AuthCookieCipher = ReversibleTestCipher(),
+ now: () -> Long = { NOW },
+ block: suspend (AuthCookieStore) -> T,
+ ): T = runBlocking {
+ val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ val dataStore: DataStore = PreferenceDataStoreFactory.create(
+ scope = scope,
+ produceFile = { fileFor(name) },
+ )
+ try {
+ block(
+ DataStoreAuthCookieStore(
+ dataStore = dataStore,
+ cipher = cipher,
+ onCipherFailure = {},
+ now = now,
+ ),
+ )
+ } finally {
+ scope.cancel()
+ scope.coroutineContext[Job]!!.join()
+ }
+ }
+
+ private fun record(hostKey: String, name: String = "webterm_auth", expiresAt: Long = FAR_FUTURE) =
+ AuthCookieRecord(
+ hostKey = hostKey,
+ name = name,
+ value = SECRET,
+ domain = "10.0.0.1",
+ path = "/",
+ expiresAtEpochMillis = expiresAt,
+ httpOnly = true,
+ )
+
+ @Test
+ fun upsert_persists_and_loadForHost_reads_back() {
+ val a = record(hostA)
+
+ onFile(newFileName()) { store ->
+ assertEquals(listOf(a), store.upsert(a))
+ assertEquals(listOf(a), store.loadForHost(hostA))
+ assertEquals(emptyList(), store.loadForHost(hostB))
+ }
+ }
+
+ @Test
+ fun the_blob_survives_a_recreated_store() {
+ // The cold-start path: a NEW store over the SAME (released) file must see the cookie.
+ val file = newFileName()
+ onFile(file) { it.upsert(record(hostA)) }
+
+ assertEquals(1, onFile(file) { it.loadForHost(hostA) }.size)
+ }
+
+ @Test
+ fun the_credential_never_appears_in_the_datastore_file() {
+ // Arrange + Act: write the cookie through the real DataStore, then read the FILE back.
+ val file = newFileName()
+ onFile(file) { it.upsert(record(hostA)) }
+
+ // Assert: the bytes on disk hold no recoverable form of the credential. This is the property
+ // that makes a rooted device, an `adb` pull, a cloud backup or a D2D transfer harmless.
+ val bytes = fileFor(file).readBytes()
+ assertTrue("the DataStore file was never written", bytes.isNotEmpty())
+ val onDisk = String(bytes, Charsets.ISO_8859_1)
+ assertFalse("the raw credential is on disk", onDisk.contains(SECRET))
+ assertFalse(
+ "the credential is on disk merely Base64'd",
+ onDisk.contains(Base64.encodeToString(SECRET.toByteArray(), Base64.NO_WRAP)),
+ )
+ }
+
+ @Test
+ fun remove_unknown_is_noop_and_removeHost_clears_one_host_only() {
+ onFile(newFileName()) { store ->
+ store.upsert(record(hostA))
+ store.upsert(record(hostB))
+
+ assertEquals(2, store.remove(record("http://nope:1").id).size)
+ assertEquals(listOf(hostB), store.removeHost(hostA).map { it.hostKey })
+ }
+ }
+
+ @Test
+ fun replaceHost_swaps_that_hosts_cookies_wholesale() {
+ onFile(newFileName()) { store ->
+ store.upsert(record(hostA, name = "stale"))
+ store.upsert(record(hostB))
+
+ val updated = store.replaceHost(hostA, listOf(record(hostA, name = "fresh")))
+
+ assertEquals(listOf("fresh"), updated.filter { it.hostKey == hostA }.map { it.name })
+ assertEquals(1, updated.count { it.hostKey == hostB })
+ }
+ }
+
+ @Test
+ fun a_record_that_expired_at_rest_is_never_returned() {
+ // Arrange: written while live, read back after the clock moved past its expiry.
+ val file = newFileName()
+ val expiresAt = 1_800_000_000_000L
+ onFile(file, now = { expiresAt - 1 }) { it.upsert(record(hostA, expiresAt = expiresAt)) }
+
+ // Act
+ val afterExpiry = onFile(file, now = { expiresAt + 1 }) { it.loadAll() }
+
+ // Assert
+ assertTrue("a stale shell credential must never be resurrected", afterExpiry.isEmpty())
+ }
+
+ @Test
+ fun nothing_is_persisted_when_the_cipher_is_unavailable() {
+ // Arrange + Act: the fail-closed path — a device whose keystore cannot seal.
+ val file = newFileName()
+ val updated = onFile(file, cipher = ReversibleTestCipher(failSeal = true)) {
+ it.upsert(record(hostA))
+ }
+
+ // Assert: usable in memory, absent from disk — never a plaintext fallback.
+ assertEquals(1, updated.size)
+ val onDisk = fileFor(file)
+ assertFalse(
+ "the credential was written despite the cipher failing",
+ onDisk.exists() && String(onDisk.readBytes(), Charsets.ISO_8859_1).contains(SECRET),
+ )
+ assertTrue(onFile(file) { it.loadAll() }.isEmpty())
+ }
+
+ /**
+ * Reversible stand-in for the Tink AEAD (XOR + Base64) — enough to exercise the store's
+ * seal/open/fail-closed paths without a Tink dependency, and confined to the test source set so it
+ * can never be wired into the app. The key is fixed so a cold-start store opens what the previous
+ * one sealed.
+ */
+ private class ReversibleTestCipher(
+ private val failSeal: Boolean = false,
+ private val key: Byte = 0x2C,
+ ) : AuthCookieCipher {
+ override fun seal(plaintext: String): String {
+ if (failSeal) throw IllegalStateException("test cipher: keystore unavailable")
+ return Base64.encodeToString(plaintext.toByteArray().xored(), Base64.NO_WRAP)
+ }
+
+ override fun open(sealed: String): String =
+ String(Base64.decode(sealed, Base64.NO_WRAP).xored())
+
+ private fun ByteArray.xored(): ByteArray =
+ ByteArray(size) { (this[it].toInt() xor key.toInt()).toByte() }
+ }
+
+ private companion object {
+ /** Distinctive enough that finding it on disk is unambiguous. */
+ const val SECRET = "SUPER_SECRET_SHELL_TOKEN_9f3a7c11"
+ const val NOW = 1_700_000_000_000L
+ const val FAR_FUTURE = 4_000_000_000_000L
+
+ fun newFileName(): String = "cookies-${UUID.randomUUID()}"
+ }
+}
diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCipher.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCipher.kt
new file mode 100644
index 0000000..4fdfa43
--- /dev/null
+++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCipher.kt
@@ -0,0 +1,47 @@
+package wang.yaojia.webterm.hostregistry
+
+/**
+ * At-rest encryption seam for the persisted `webterm_auth` cookie.
+ *
+ * WHY A SEAM AND NOT A CONCRETE CIPHER: the stored cookie is a **full-shell credential** and plan §8
+ * already requires the far-less-powerful device-cert chain to be Tink-AEAD encrypted under an
+ * `AndroidKeyStore` master key. The same treatment is therefore mandatory here — but the Tink +
+ * AndroidKeyStore implementation lives in `:client-tls-android`, and `:host-registry` must NOT gain
+ * that dependency (`android/README.md`: *dependencies only flow down; nothing points upward* — the two
+ * are siblings under `:app`). So the operation contract is declared HERE and implemented THERE, with
+ * `:app` bridging the two — exactly the precedent `:transport-okhttp`'s `ClientIdentityProvider` set
+ * for the mTLS material.
+ *
+ * ── Contract for implementations (security-load-bearing) ──────────────────────────────────────────
+ * - [seal] must produce a value the plaintext **cannot** be recovered from without a key that never
+ * leaves the device (i.e. real authenticated encryption — an encoding such as Base64 is NOT a
+ * cipher and is a contract violation).
+ * - [open] must be **authenticated**: a tampered, truncated or foreign blob must throw, never return
+ * attacker-shaped plaintext.
+ * - Failure is signalled by throwing. Both directions may fail (keystore unavailable, key lost after
+ * a restore-to-new-device, tamper) and [DataStoreAuthCookieStore] fails CLOSED on either — a seal
+ * failure drops persistence rather than falling back to plaintext.
+ * - Neither the plaintext nor any part of it may appear in an exception message, a log line or
+ * `toString()`. The plaintext handed to [seal] contains the credential.
+ *
+ * There is deliberately **no** in-`main` reversible/no-op implementation (unlike [InMemoryHostStore]
+ * and [InMemoryAuthCookieStore], which are harmless): a pass-through "cipher" reachable from DI would
+ * silently reinstate plaintext persistence. Test doubles live in test source sets only. When no real
+ * cipher can be built, bind [InMemoryAuthCookieStore] instead — memory-only persistence is the
+ * fail-closed answer (the user re-authenticates after a restart).
+ */
+public interface AuthCookieCipher {
+ /**
+ * Encrypt [plaintext] into an opaque, storable string.
+ *
+ * @throws Exception if encryption is unavailable — the caller then persists NOTHING.
+ */
+ public fun seal(plaintext: String): String
+
+ /**
+ * Decrypt what [seal] produced.
+ *
+ * @throws Exception if [sealed] is not an intact blob this device's key can open.
+ */
+ public fun open(sealed: String): String
+}
diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodec.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodec.kt
new file mode 100644
index 0000000..d87f1ab
--- /dev/null
+++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodec.kt
@@ -0,0 +1,86 @@
+package wang.yaojia.webterm.hostregistry
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+/**
+ * At-rest DTO for an [AuthCookieRecord]. Separate from the domain type so the storage format can
+ * drift independently, and so nothing but this file ever serialises the credential.
+ *
+ * [toString] is redacted here too: a decode failure or a debug print of the intermediate list must
+ * not spill the cookie value.
+ */
+@Serializable
+internal data class PersistedAuthCookie(
+ val hostKey: String,
+ val name: String,
+ val value: String,
+ val domain: String,
+ val path: String,
+ val expiresAtEpochMillis: Long,
+ val secure: Boolean = false,
+ val httpOnly: Boolean = false,
+ val hostOnly: Boolean = true,
+) {
+ override fun toString(): String = "PersistedAuthCookie(hostKey=$hostKey, name=$name, value=)"
+}
+
+/**
+ * Pure JSON codec for the persisted auth-cookie list (JVM-testable without a Context, mirrors
+ * [HostCodec]). Encode is total; decode is defensive — cookie records are UNTRUSTED at rest:
+ * - a corrupt/undecodable blob → empty list (start clean, never crash);
+ * - a record with a blank `hostKey`, `name` or `domain` → dropped (a record with no host scope
+ * could otherwise be replayed to the wrong host);
+ * - a record that expired while at rest → dropped, so a stale shell credential is never
+ * resurrected after a long background or a clock jump.
+ */
+internal object AuthCookieCodec {
+ private val json = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ encodeDefaults = true
+ }
+
+ fun encode(records: List): String =
+ json.encodeToString(records.map { it.toPersisted() })
+
+ fun decode(raw: String?, nowEpochMillis: Long): List {
+ if (raw.isNullOrBlank()) return emptyList()
+ val persisted = try {
+ json.decodeFromString>(raw)
+ } catch (_: Exception) {
+ return emptyList() // corrupted blob at rest → start clean, never crash
+ }
+ return persisted.mapNotNull { it.toRecordOrNull() }.droppingExpired(nowEpochMillis)
+ }
+
+ private fun AuthCookieRecord.toPersisted(): PersistedAuthCookie =
+ PersistedAuthCookie(
+ hostKey = hostKey,
+ name = name,
+ value = value,
+ domain = domain,
+ path = path,
+ expiresAtEpochMillis = expiresAtEpochMillis,
+ secure = secure,
+ httpOnly = httpOnly,
+ hostOnly = hostOnly,
+ )
+
+ private fun PersistedAuthCookie.toRecordOrNull(): AuthCookieRecord? {
+ if (hostKey.isBlank() || name.isBlank() || domain.isBlank()) return null
+ return AuthCookieRecord(
+ hostKey = hostKey,
+ name = name,
+ value = value,
+ domain = domain,
+ path = path,
+ expiresAtEpochMillis = expiresAtEpochMillis,
+ secure = secure,
+ httpOnly = httpOnly,
+ hostOnly = hostOnly,
+ )
+ }
+}
diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieRecord.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieRecord.kt
new file mode 100644
index 0000000..9cb8836
--- /dev/null
+++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieRecord.kt
@@ -0,0 +1,51 @@
+package wang.yaojia.webterm.hostregistry
+
+/**
+ * One persisted session cookie, scoped to exactly one paired host.
+ *
+ * Background: when the server runs with `WEBTERM_TOKEN` set (`src/http/auth.ts`), pairing yields a
+ * `webterm_auth` cookie and every later remote HTTP route — plus the WS upgrade — requires it.
+ * `:transport-okhttp`'s `AuthCookieJar` owns the live cookie semantics; this module only makes the
+ * cookie survive a process restart.
+ *
+ * - [hostKey] is the isolation key produced by `authCookieHostKey` (`scheme://host:port`). It is the
+ * reason a record can never be replayed to a different host: reads go through [forHost].
+ * - [expiresAtEpochMillis] is ABSOLUTE wall-clock milliseconds, never a `Max-Age` duration —
+ * persisting a duration would restart the credential's lifetime on every load.
+ * - [value] is a **shell credential**. [toString] is redacted so it can never land in a log, a crash
+ * report or a debugger dump of a surrounding data class.
+ */
+public data class AuthCookieRecord(
+ val hostKey: String,
+ val name: String,
+ val value: String,
+ val domain: String,
+ val path: String,
+ val expiresAtEpochMillis: Long,
+ val secure: Boolean = false,
+ val httpOnly: Boolean = false,
+ val hostOnly: Boolean = true,
+) {
+ /** Storage identity: our host scope plus RFC 6265's (domain, path, name) cookie key. */
+ val id: AuthCookieId
+ get() = AuthCookieId(hostKey = hostKey, domain = domain, path = path, name = name)
+
+ /** True iff the record is still live at [nowEpochMillis] (expiry is inclusive → already dead). */
+ public fun isLiveAt(nowEpochMillis: Long): Boolean = expiresAtEpochMillis > nowEpochMillis
+
+ override fun toString(): String =
+ "AuthCookieRecord(hostKey=$hostKey, name=$name, value=, domain=$domain, path=$path, " +
+ "expiresAtEpochMillis=$expiresAtEpochMillis, secure=$secure, httpOnly=$httpOnly, hostOnly=$hostOnly)"
+}
+
+/**
+ * The composite identity of a stored cookie. A value class rather than a joined string on purpose:
+ * cookie names and paths may legally contain the separator characters a string key would need, and a
+ * collision there would let one record silently overwrite another.
+ */
+public data class AuthCookieId(
+ val hostKey: String,
+ val domain: String,
+ val path: String,
+ val name: String,
+)
diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStore.kt
new file mode 100644
index 0000000..2dcad29
--- /dev/null
+++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStore.kt
@@ -0,0 +1,74 @@
+package wang.yaojia.webterm.hostregistry
+
+/**
+ * Durable per-host storage for the `webterm_auth` session cookie (plan §3 :host-registry, server
+ * `src/http/auth.ts`). Implementations: [DataStoreAuthCookieStore] (real) and
+ * [InMemoryAuthCookieStore] (in-`main` double, exactly like [InMemoryHostStore]).
+ *
+ * Same immutable style as [HostStore]: every mutation RETURNS the new collection instead of mutating
+ * shared state, upsert preserves position, and an unknown id is an explicit no-op (never a throw).
+ *
+ * Host isolation is part of the contract, not a convention: [loadForHost] must never return a record
+ * filed under a different `hostKey`, and [replaceHost] must never file another host's record under
+ * the host being replaced. The stored value is a shell credential.
+ */
+public interface AuthCookieStore {
+ /** Every stored cookie across all hosts, in stored (insertion) order. */
+ public suspend fun loadAll(): List
+
+ /** Only [hostKey]'s cookies — the cold-start hydration read. Unknown host → empty list. */
+ public suspend fun loadForHost(hostKey: String): List
+
+ /** Insert, or replace the record with the same [AuthCookieRecord.id] in place. Returns the new list. */
+ public suspend fun upsert(record: AuthCookieRecord): List
+
+ /** Remove one record. Unknown [id] is a no-op returning the unchanged collection. */
+ public suspend fun remove(id: AuthCookieId): List
+
+ /** Forget everything stored for [hostKey] (unpair / sign-out). Other hosts are untouched. */
+ public suspend fun removeHost(hostKey: String): List
+
+ /**
+ * Swap [hostKey]'s cookies for [records] wholesale — the write the OkHttp cookie jar drives
+ * ("this is host X's cookie set now"). An empty [records] clears the host. Records whose
+ * `hostKey` differs are DROPPED rather than trusted.
+ */
+ public suspend fun replaceHost(hostKey: String, records: List): List
+}
+
+// ── Pure immutable transforms shared by every AuthCookieStore (DRY, mirrors HostStore.kt) ─────────
+// Never mutate the receiver — always return a fresh list.
+
+/** Insert [record], or replace the entry with the same id IN PLACE (position preserved). */
+internal fun List.upserting(record: AuthCookieRecord): List =
+ if (any { it.id == record.id }) {
+ map { if (it.id == record.id) record else it }
+ } else {
+ this + record
+ }
+
+/** A new list without the record whose id equals [id]. Unknown id → unchanged contents (no-op). */
+internal fun List.removing(id: AuthCookieId): List =
+ filter { it.id != id }
+
+/** Only the records belonging to [hostKey] — THE cross-host leak guard on the read path. */
+internal fun List.forHost(hostKey: String): List =
+ filter { it.hostKey == hostKey }
+
+/** A new list with every record of [hostKey] removed; other hosts untouched. */
+internal fun List.removingHost(hostKey: String): List =
+ filter { it.hostKey != hostKey }
+
+/**
+ * A new list where [hostKey]'s records are exactly [records] (appended after the surviving records
+ * of other hosts). Records carrying a different `hostKey` are dropped — the write path's half of the
+ * cross-host leak guard.
+ */
+internal fun List.replacingHost(
+ hostKey: String,
+ records: List,
+): List = removingHost(hostKey) + records.forHost(hostKey)
+
+/** A new list without records that already expired at [nowEpochMillis]. */
+internal fun List.droppingExpired(nowEpochMillis: Long): List =
+ filter { it.isLiveAt(nowEpochMillis) }
diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStore.kt
new file mode 100644
index 0000000..102d47d
--- /dev/null
+++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStore.kt
@@ -0,0 +1,123 @@
+package wang.yaojia.webterm.hostregistry
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.MutablePreferences
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.first
+
+/**
+ * Preferences-DataStore-backed [AuthCookieStore]. The whole cookie list is ONE JSON string
+ * ([AuthCookieCodec]) which is then **[AuthCookieCipher.seal]ed** before it is written, so the single
+ * stored value under [SEALED_COOKIES_KEY] is authenticated ciphertext, never the credential.
+ *
+ * ## Storage posture (plan §8)
+ * The stored `webterm_auth` cookie is a **full-shell credential** the server keeps alive for 30 days
+ * (`Max-Age=2592000`, `src/http/auth.ts`). Preferences DataStore is app-private but plaintext, and
+ * app-private is not a secrecy boundary against a rooted device, an `adb` pull of a debuggable build,
+ * a cloud backup or a device-to-device transfer. Plan §8 already requires the far weaker device-cert
+ * chain to be Tink-AEAD encrypted under an `AndroidKeyStore` master key, so this blob gets the SAME
+ * mechanism, injected through the [AuthCookieCipher] seam (`:client-tls-android` implements it; see
+ * that interface for why it is a seam and not a direct dependency). `android:allowBackup="false"` in
+ * the app manifest is the second, independent layer.
+ *
+ * ## Fail CLOSED, never fall back to plaintext
+ * If the cipher cannot seal (keystore unavailable, key lost after a restore onto a new device) the
+ * write persists **nothing** and additionally DELETES any previously stored blob, then reports the
+ * failure through [onCipherFailure]. The cost is that the user re-authenticates after a restart; the
+ * alternative — writing the credential in the clear — is the exact thing this class exists to prevent.
+ * A blob that fails to open is treated as "no stored cookies" for the same reason.
+ *
+ * The [DataStore] is injected (constructed from a Context in `:app` DI) so this class has no
+ * Android-framework surface of its own; the on-device behaviour is verified instrumented (androidTest)
+ * and the at-rest properties on the JVM (`DataStoreAuthCookieStoreCryptoTest`).
+ *
+ * @param cipher at-rest encryption for the serialized cookie list. Required — there is deliberately
+ * no default, so no wiring can end up persisting plaintext by omission.
+ * @param onCipherFailure invoked when a seal/open fails, i.e. when a credential was dropped instead of
+ * stored or restored. Never handed the cookie value. Callers must not log the [Throwable]'s payload
+ * beyond its type/message (the [AuthCookieCipher] contract keeps plaintext out of both), and must not
+ * throw — it runs inside the DataStore write transaction, so a throwing reporter would abort the write.
+ * @param now wall-clock source, injected so expiry-at-rest is testable.
+ */
+public class DataStoreAuthCookieStore(
+ private val dataStore: DataStore,
+ private val cipher: AuthCookieCipher,
+ private val onCipherFailure: (Throwable) -> Unit,
+ private val now: () -> Long = System::currentTimeMillis,
+) : AuthCookieStore {
+
+ override suspend fun loadAll(): List = decode(dataStore.data.first()[SEALED_COOKIES_KEY])
+
+ override suspend fun loadForHost(hostKey: String): List =
+ loadAll().forHost(hostKey)
+
+ override suspend fun upsert(record: AuthCookieRecord): List =
+ writeTransform { it.upserting(record) }
+
+ override suspend fun remove(id: AuthCookieId): List =
+ writeTransform { it.removing(id) }
+
+ override suspend fun removeHost(hostKey: String): List =
+ writeTransform { it.removingHost(hostKey) }
+
+ override suspend fun replaceHost(
+ hostKey: String,
+ records: List,
+ ): List = writeTransform { it.replacingHost(hostKey, records) }
+
+ private suspend fun writeTransform(
+ transform: (List) -> List,
+ ): List {
+ lateinit var updated: List
+ dataStore.edit { prefs ->
+ // Decode drops expired records, so every write also garbage-collects dead credentials.
+ updated = transform(decode(prefs[SEALED_COOKIES_KEY]))
+ val sealed = if (updated.isEmpty()) null else seal(AuthCookieCodec.encode(updated))
+ // sealed == null means either "nothing left to store" or "sealing failed" — both write
+ // NOTHING and drop the previous blob, so no unencrypted or superseded credential lingers.
+ if (sealed == null) prefs.remove(SEALED_COOKIES_KEY) else prefs[SEALED_COOKIES_KEY] = sealed
+ prefs.purgeLegacyPlaintext()
+ }
+ return updated
+ }
+
+ /** Open + decode, dropping anything expired. An unopenable blob reads as "no cookies" (fail closed). */
+ private fun decode(sealed: String?): List {
+ if (sealed.isNullOrBlank()) return emptyList()
+ val json = try {
+ cipher.open(sealed)
+ } catch (e: Exception) {
+ onCipherFailure(e)
+ return emptyList()
+ }
+ return AuthCookieCodec.decode(json, now())
+ }
+
+ /** Encrypt, or null when the cipher is unavailable — the caller then persists nothing. */
+ private fun seal(json: String): String? =
+ try {
+ cipher.seal(json)
+ } catch (e: Exception) {
+ onCipherFailure(e)
+ null
+ }
+
+ /**
+ * Belt-and-braces: an early build of this store wrote the cookie list to [LEGACY_PLAINTEXT_KEY]
+ * unencrypted. That key is never read, and is removed by the first write, so a plaintext credential
+ * cannot outlive the upgrade on a device that ran such a build.
+ */
+ private fun MutablePreferences.purgeLegacyPlaintext() {
+ remove(LEGACY_PLAINTEXT_KEY) // absent → no-op, and DataStore skips the write when nothing changed
+ }
+
+ private companion object {
+ /** The one stored value: `AuthCookieCipher.seal(AuthCookieCodec.encode(records))`. */
+ val SEALED_COOKIES_KEY = stringPreferencesKey("authCookiesSealed")
+
+ /** Pre-encryption key — write-purged, never read. Do not reuse this name. */
+ val LEGACY_PLAINTEXT_KEY = stringPreferencesKey("authCookies")
+ }
+}
diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStore.kt
new file mode 100644
index 0000000..e4fdace
--- /dev/null
+++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStore.kt
@@ -0,0 +1,48 @@
+package wang.yaojia.webterm.hostregistry
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/**
+ * In-memory [AuthCookieStore]. Lives in `main` (not `test`) on purpose — it stands in for the
+ * DataStore store in this module's contract tests AND in the AW4 wiring/ViewModel tests, exactly
+ * like [InMemoryHostStore].
+ *
+ * A [Mutex] serialises each read-modify-write; the state is a value snapshot replaced wholesale on
+ * every change (no in-place mutation of a shared reference).
+ */
+public class InMemoryAuthCookieStore(
+ initial: List = emptyList(),
+) : AuthCookieStore {
+ private val mutex = Mutex()
+
+ // Defensive copy so an external mutable list handed to the ctor can't alias our state.
+ private var records: List = initial.toList()
+
+ override suspend fun loadAll(): List = mutex.withLock { records }
+
+ override suspend fun loadForHost(hostKey: String): List =
+ mutex.withLock { records.forHost(hostKey) }
+
+ override suspend fun upsert(record: AuthCookieRecord): List =
+ write { it.upserting(record) }
+
+ override suspend fun remove(id: AuthCookieId): List =
+ write { it.removing(id) }
+
+ override suspend fun removeHost(hostKey: String): List =
+ write { it.removingHost(hostKey) }
+
+ override suspend fun replaceHost(
+ hostKey: String,
+ records: List,
+ ): List = write { it.replacingHost(hostKey, records) }
+
+ private suspend fun write(
+ transform: (List) -> List,
+ ): List = mutex.withLock {
+ val updated = transform(records)
+ records = updated
+ updated
+ }
+}
diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodecTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodecTest.kt
new file mode 100644
index 0000000..46099a3
--- /dev/null
+++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodecTest.kt
@@ -0,0 +1,98 @@
+package wang.yaojia.webterm.hostregistry
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * At-rest encoding for the auth-cookie list. Cookie records are UNTRUSTED at rest (same posture as
+ * `HostCodec`): a corrupt blob starts clean, a structurally invalid record is dropped, and an
+ * already-expired record is dropped on read so a stale shell credential is never resurrected after
+ * a long background.
+ */
+class AuthCookieCodecTest {
+
+ private fun record(
+ hostKey: String = "http://192.168.1.5:3000",
+ name: String = "webterm_auth",
+ expiresAtEpochMillis: Long = FAR_FUTURE,
+ ) = AuthCookieRecord(
+ hostKey = hostKey,
+ name = name,
+ value = TOKEN,
+ domain = "192.168.1.5",
+ path = "/",
+ expiresAtEpochMillis = expiresAtEpochMillis,
+ secure = false,
+ httpOnly = true,
+ hostOnly = true,
+ )
+
+ @Test
+ fun roundTripsEveryFieldOfALiveRecord() {
+ // Arrange
+ val original = listOf(record(), record(hostKey = "https://tunnel.example:443", name = "other"))
+
+ // Act
+ val decoded = AuthCookieCodec.decode(AuthCookieCodec.encode(original), NOW)
+
+ // Assert
+ assertEquals(original, decoded)
+ }
+
+ @Test
+ fun decodingACorruptBlobStartsClean() {
+ assertEquals(emptyList(), AuthCookieCodec.decode("{not json", NOW))
+ }
+
+ @Test
+ fun decodingAnAbsentOrBlankBlobIsEmpty() {
+ assertEquals(emptyList(), AuthCookieCodec.decode(null, NOW))
+ assertEquals(emptyList(), AuthCookieCodec.decode(" ", NOW))
+ }
+
+ @Test
+ fun dropsARecordWithABlankHostKeyOrName() {
+ // Arrange: a record with no host key could otherwise be replayed to ANY host.
+ val raw = AuthCookieCodec.encode(listOf(record(hostKey = ""), record(name = ""), record()))
+
+ // Act
+ val decoded = AuthCookieCodec.decode(raw, NOW)
+
+ // Assert
+ assertEquals(1, decoded.size)
+ assertEquals("http://192.168.1.5:3000", decoded.single().hostKey)
+ }
+
+ @Test
+ fun dropsARecordThatExpiredWhileAtRest() {
+ // Arrange
+ val raw = AuthCookieCodec.encode(listOf(record(expiresAtEpochMillis = NOW), record(name = "live")))
+
+ // Act
+ val decoded = AuthCookieCodec.decode(raw, NOW)
+
+ // Assert
+ assertEquals(listOf("live"), decoded.map { it.name })
+ }
+
+ @Test
+ fun theEncodedBlobIsTheOnlyPlaceTheCredentialAppears() {
+ // Arrange
+ val records = listOf(record())
+
+ // Act
+ val rendered = records.toString() + records.single().toString() + AuthCookieCodec.toString()
+
+ // Assert: the blob must hold the value (it IS the persisted credential), but no toString may.
+ assertTrue(AuthCookieCodec.encode(records).contains(TOKEN))
+ assertFalse(rendered.contains(TOKEN), "credential leaked into a toString path: $rendered")
+ }
+
+ private companion object {
+ const val TOKEN = "s3cr3t-webterm-token-value"
+ const val NOW = 1_800_000_000_000L
+ const val FAR_FUTURE = 4_000_000_000_000L
+ }
+}
diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStoreTransformsTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStoreTransformsTest.kt
new file mode 100644
index 0000000..928a2bb
--- /dev/null
+++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStoreTransformsTest.kt
@@ -0,0 +1,172 @@
+package wang.yaojia.webterm.hostregistry
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNotSame
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * The pure immutable list transforms behind every [AuthCookieStore] — same discipline as the
+ * `HostStore` transforms: never mutate the receiver, upsert preserves position, an unknown id is
+ * an explicit no-op. Host isolation is a transform-level property here: `forHost` NEVER returns a
+ * record filed under a different host key (a shell credential — a cross-host leak is a security
+ * bug), and `removingHost` never touches another host's records.
+ */
+class AuthCookieStoreTransformsTest {
+
+ private val hostA = "http://192.168.1.5:3000"
+ private val hostB = "https://h7fd8.terminal.yaojia.wang:443"
+
+ private fun record(
+ hostKey: String,
+ name: String = "webterm_auth",
+ value: String = "token-$hostKey",
+ path: String = "/",
+ expiresAtEpochMillis: Long = FAR_FUTURE,
+ ) = AuthCookieRecord(
+ hostKey = hostKey,
+ name = name,
+ value = value,
+ domain = hostKey.substringAfter("://").substringBefore(":"),
+ path = path,
+ expiresAtEpochMillis = expiresAtEpochMillis,
+ )
+
+ @Test
+ fun upsertingAppendsAnUnknownRecordAndLeavesTheReceiverUntouched() {
+ // Arrange
+ val original = listOf(record(hostA))
+
+ // Act
+ val updated = original.upserting(record(hostB))
+
+ // Assert
+ assertEquals(1, original.size, "the receiver must never be mutated")
+ assertEquals(listOf(hostA, hostB), updated.map { it.hostKey })
+ assertNotSame(original, updated)
+ }
+
+ @Test
+ fun upsertingReplacesTheSameIdInPlacePreservingPosition() {
+ // Arrange
+ val original = listOf(record(hostA, value = "old"), record(hostB))
+
+ // Act
+ val updated = original.upserting(record(hostA, value = "new"))
+
+ // Assert
+ assertEquals(listOf(hostA, hostB), updated.map { it.hostKey })
+ assertEquals("new", updated.first().value)
+ assertEquals("old", original.first().value)
+ }
+
+ @Test
+ fun aDifferentCookieNameOnTheSameHostIsASeparateRecord() {
+ // Arrange
+ val original = listOf(record(hostA, name = "webterm_auth"))
+
+ // Act
+ val updated = original.upserting(record(hostA, name = "other"))
+
+ // Assert: identity is (hostKey, domain, path, name) — RFC 6265's key plus our host scope.
+ assertEquals(2, updated.size)
+ }
+
+ @Test
+ fun removingAnUnknownIdIsANoOp() {
+ // Arrange
+ val original = listOf(record(hostA))
+
+ // Act
+ val updated = original.removing(record(hostB).id)
+
+ // Assert
+ assertEquals(original, updated)
+ }
+
+ @Test
+ fun removingDropsOnlyTheMatchingRecord() {
+ // Arrange
+ val target = record(hostA)
+ val original = listOf(target, record(hostB))
+
+ // Act
+ val updated = original.removing(target.id)
+
+ // Assert
+ assertEquals(listOf(hostB), updated.map { it.hostKey })
+ assertEquals(2, original.size)
+ }
+
+ @Test
+ fun forHostReturnsOnlyThatHostsRecords() {
+ // Arrange
+ val original = listOf(record(hostA), record(hostB), record(hostA, name = "second"))
+
+ // Act
+ val forA = original.forHost(hostA)
+
+ // Assert
+ assertTrue(forA.all { it.hostKey == hostA }, "a host's cookies must never include another host's")
+ assertEquals(2, forA.size)
+ }
+
+ @Test
+ fun removingHostClearsOneHostAndLeavesTheOtherIntact() {
+ // Arrange
+ val original = listOf(record(hostA), record(hostB), record(hostA, name = "second"))
+
+ // Act
+ val updated = original.removingHost(hostA)
+
+ // Assert
+ assertEquals(listOf(hostB), updated.map { it.hostKey })
+ }
+
+ @Test
+ fun replacingHostSwapsOneHostsRecordsWholesale() {
+ // Arrange
+ val original = listOf(record(hostA, value = "stale"), record(hostB))
+
+ // Act
+ val updated = original.replacingHost(hostA, listOf(record(hostA, value = "fresh")))
+
+ // Assert
+ assertEquals(setOf(hostA, hostB), updated.map { it.hostKey }.toSet())
+ assertEquals("fresh", updated.single { it.hostKey == hostA }.value)
+ }
+
+ @Test
+ fun replacingHostRejectsRecordsFiledUnderADifferentHost() {
+ // Arrange: a mis-wired caller must not be able to smuggle host B's cookie under host A.
+ val original = listOf(record(hostB))
+
+ // Act
+ val updated = original.replacingHost(hostA, listOf(record(hostB, value = "smuggled")))
+
+ // Assert
+ assertFalse(
+ updated.any { it.hostKey == hostB && it.value == "smuggled" },
+ "replacingHost must drop records whose hostKey != the host being replaced",
+ )
+ }
+
+ @Test
+ fun droppingExpiredKeepsOnlyLiveRecords() {
+ // Arrange
+ val live = record(hostA, expiresAtEpochMillis = NOW + 1)
+ val expired = record(hostB, expiresAtEpochMillis = NOW)
+
+ // Act
+ val updated = listOf(live, expired).droppingExpired(NOW)
+
+ // Assert: expiry is inclusive — at the instant of expiry the cookie is already dead.
+ assertEquals(listOf(live), updated)
+ }
+
+ private companion object {
+ const val NOW = 1_800_000_000_000L
+ const val FAR_FUTURE = 4_000_000_000_000L
+ }
+}
diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreCryptoTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreCryptoTest.kt
new file mode 100644
index 0000000..1837ebf
--- /dev/null
+++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreCryptoTest.kt
@@ -0,0 +1,292 @@
+package wang.yaojia.webterm.hostregistry
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.preferencesOf
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+
+/**
+ * The AT-REST posture of [DataStoreAuthCookieStore] — what actually lands in the backing store.
+ *
+ * The persisted `webterm_auth` cookie is a **full-shell credential** with a 30-day server-side
+ * `Max-Age` (`src/http/auth.ts`), so plan §8's "encrypted, never in the clear" rule for the far
+ * weaker device-cert chain applies to it at least as strongly. These are the tests that would go red
+ * if the store ever wrote the value where a rooted device, an `adb` pull of a debug build, a cloud
+ * backup or a device-to-device transfer could read it.
+ *
+ * These run on the JVM (no Context) against a recording [DataStore] double, so the exact stored
+ * `Preferences` can be inspected byte-for-byte. The real Tink/AndroidKeyStore cipher is exercised
+ * instrumented in `:client-tls-android` (`TinkAuthCookieCipherTest`), and the DataStore file itself is
+ * checked on-device in `DataStoreAuthCookieStoreTest`.
+ */
+@DisplayName("DataStoreAuthCookieStore — at-rest encryption")
+class DataStoreAuthCookieStoreCryptoTest {
+
+ private val hostA = "http://192.168.1.5:3000"
+ private val hostB = "https://tunnel.example:443"
+
+ private fun record(
+ hostKey: String = hostA,
+ name: String = "webterm_auth",
+ value: String = SECRET,
+ expiresAt: Long = FAR_FUTURE,
+ ) = AuthCookieRecord(
+ hostKey = hostKey,
+ name = name,
+ value = value,
+ domain = "192.168.1.5",
+ path = "/",
+ expiresAtEpochMillis = expiresAt,
+ httpOnly = true,
+ )
+
+ // ── The properties that matter ────────────────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("the cookie value never lands in the backing store in any recoverable form")
+ fun theCookieValueIsNeverStoredRecoverably() = runTest {
+ // Arrange
+ val dataStore = RecordingPreferencesDataStore()
+ val store = newStore(dataStore)
+
+ // Act
+ store.upsert(record())
+
+ // Assert: nothing anywhere in the persisted Preferences reveals the credential — not raw, not
+ // Base64'd, not hex, not percent-encoded (a "cipher" that merely re-encodes must fail here).
+ val onDisk = dataStore.dump()
+ assertTrue(onDisk.isNotEmpty(), "the store wrote nothing at all — the round-trip test guards that")
+ recoverableFormsOf(SECRET).forEach { form ->
+ assertFalse(
+ onDisk.contains(form),
+ "a shell credential leaked into the backing store as <$form>: $onDisk",
+ )
+ }
+ }
+
+ @Test
+ @DisplayName("what is stored is the sealed blob, and it round-trips back through the cipher")
+ fun theSealedBlobRoundTrips() = runTest {
+ // Arrange
+ val dataStore = RecordingPreferencesDataStore()
+ val cipher = ReversibleTestCipher()
+ val a = record()
+ val b = record(hostKey = hostB, value = "other-host-token")
+
+ // Act
+ newStore(dataStore, cipher).upsert(a)
+ newStore(dataStore, cipher).upsert(b)
+ val reloaded = newStore(dataStore, cipher)
+
+ // Assert: a cold-start store over the same bytes sees both cookies, each on its own host.
+ assertEquals(listOf(a, b), reloaded.loadAll())
+ assertEquals(listOf(a), reloaded.loadForHost(hostA))
+ // ...and the ciphertext is an encryption OF the codec's JSON, not a per-field mangling.
+ assertEquals(AuthCookieCodec.encode(listOf(a, b)), cipher.open(dataStore.sealedBlob()!!))
+ }
+
+ @Test
+ @DisplayName("fail closed: when the cipher cannot seal, NOTHING is persisted (no plaintext fallback)")
+ fun aSealFailurePersistsNothing() = runTest {
+ // Arrange
+ val dataStore = RecordingPreferencesDataStore()
+ val failures = mutableListOf()
+ val store = newStore(
+ dataStore,
+ ReversibleTestCipher(failSeal = true),
+ onCipherFailure = { failures += it },
+ )
+
+ // Act
+ val updated = store.upsert(record())
+
+ // Assert: the in-memory answer is still correct (this process keeps working)...
+ assertEquals(listOf(record()), updated)
+ // ...but the credential never reached the disk, and the failure was reported, not swallowed.
+ assertEquals(emptyMap, Any>(), dataStore.current().asMap())
+ assertEquals(1, failures.size, "a dropped persistence must be reported to the wiring")
+ }
+
+ @Test
+ @DisplayName("fail closed: a previously sealed blob is DELETED when sealing starts failing")
+ fun aSealFailureAlsoDropsTheStaleBlob() = runTest {
+ // Arrange: a good cipher persisted host A's cookie...
+ val dataStore = RecordingPreferencesDataStore()
+ val cipher = ReversibleTestCipher()
+ newStore(dataStore, cipher).upsert(record())
+ assertTrue(dataStore.sealedBlob() != null, "arrange failed: nothing was sealed")
+
+ // Act: ...then the keystore breaks (open still works, seal does not) and the set changes.
+ newStore(dataStore, ReversibleTestCipher(failSeal = true, failOpen = false, key = cipher.key))
+ .replaceHost(hostA, listOf(record(value = "rotated")))
+
+ // Assert: the superseded blob is gone rather than left behind as a resurrectable credential.
+ assertEquals(null, dataStore.sealedBlob())
+ }
+
+ @Test
+ @DisplayName("an unreadable blob (tamper / key lost with the device) reads as no cookies")
+ fun anUnreadableBlobYieldsNoCookies() = runTest {
+ // Arrange: sealed by a cipher whose key this store does not have.
+ val dataStore = RecordingPreferencesDataStore()
+ newStore(dataStore, ReversibleTestCipher(key = 0x5A)).upsert(record())
+ val failures = mutableListOf()
+ val store = newStore(
+ dataStore,
+ ReversibleTestCipher(failOpen = true),
+ onCipherFailure = { failures += it },
+ )
+
+ // Act
+ val loaded = store.loadAll()
+
+ // Assert
+ assertEquals(emptyList(), loaded)
+ assertEquals(1, failures.size, "an unopenable credential blob must be reported")
+ }
+
+ @Test
+ @DisplayName("a legacy PLAINTEXT blob is never read, and is deleted by the next write")
+ fun aLegacyPlaintextBlobIsPurged() = runTest {
+ // Arrange: what a pre-encryption build would have left behind under the old key.
+ val legacyKey = stringPreferencesKey("authCookies")
+ val dataStore = RecordingPreferencesDataStore(
+ preferencesOf(legacyKey to AuthCookieCodec.encode(listOf(record()))),
+ )
+ val store = newStore(dataStore)
+
+ // Act + Assert: never read...
+ assertEquals(emptyList(), store.loadAll())
+
+ // ...and the plaintext is gone after one write, so it cannot outlive the upgrade.
+ store.upsert(record(value = "fresh"))
+ assertEquals(null, dataStore.current()[legacyKey])
+ assertFalse(dataStore.dump().contains(SECRET), "the legacy plaintext survived: ${dataStore.dump()}")
+ }
+
+ @Test
+ @DisplayName("clearing the last cookie removes the blob instead of storing an empty one")
+ fun clearingRemovesTheBlob() = runTest {
+ // Arrange
+ val dataStore = RecordingPreferencesDataStore()
+ val cipher = ReversibleTestCipher()
+ val store = newStore(dataStore, cipher)
+ store.upsert(record())
+
+ // Act
+ store.removeHost(hostA)
+
+ // Assert
+ assertEquals(null, dataStore.sealedBlob())
+ }
+
+ @Test
+ @DisplayName("a record that expired at rest is dropped on read and garbage-collected on write")
+ fun expiryAtRestStillApplies() = runTest {
+ // Arrange: sealed while live, read back after the clock passed its expiry.
+ val dataStore = RecordingPreferencesDataStore()
+ val cipher = ReversibleTestCipher()
+ val expiresAt = 1_800_000_000_000L
+ newStore(dataStore, cipher, now = { expiresAt - 1 }).upsert(record(expiresAt = expiresAt))
+
+ // Act
+ val afterExpiry = newStore(dataStore, cipher, now = { expiresAt + 1 })
+
+ // Assert
+ assertEquals(emptyList(), afterExpiry.loadAll())
+ assertEquals(
+ listOf(hostB),
+ afterExpiry.upsert(record(hostKey = hostB)).map { it.hostKey },
+ "a write must also garbage-collect the expired credential, not just hide it on read",
+ )
+ }
+
+ // ── Doubles ──────────────────────────────────────────────────────────────────────────────────
+
+ private fun newStore(
+ dataStore: DataStore,
+ cipher: AuthCookieCipher = ReversibleTestCipher(),
+ now: () -> Long = { NOW },
+ onCipherFailure: (Throwable) -> Unit = {},
+ ) = DataStoreAuthCookieStore(
+ dataStore = dataStore,
+ cipher = cipher,
+ onCipherFailure = onCipherFailure,
+ now = now,
+ )
+
+ /**
+ * In-memory [DataStore] that keeps the exact [Preferences] the store wrote, so a test can inspect
+ * every key and value. Not a cipher test double — the real DataStore is byte-identical in what it
+ * is HANDED, which is the only thing these tests assert about.
+ */
+ private class RecordingPreferencesDataStore(
+ initial: Preferences = emptyPreferences(),
+ ) : DataStore {
+ private val state = MutableStateFlow(initial)
+
+ override val data: Flow = state
+
+ override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences {
+ val updated = transform(state.value)
+ state.value = updated
+ return updated
+ }
+
+ fun current(): Preferences = state.value
+
+ fun sealedBlob(): String? = state.value[stringPreferencesKey("authCookiesSealed")]
+
+ /** Every key AND value as one haystack — catches a leak under ANY key, not just the known one. */
+ fun dump(): String = state.value.asMap().entries.joinToString(";") { "${it.key.name}=${it.value}" }
+ }
+
+ /**
+ * A reversible stand-in for the real Tink AEAD: XOR under a byte key, then Base64. Strong enough
+ * for what these tests assert (the stored form is neither the plaintext nor an encoding of it, and
+ * a different key cannot open it) and weak on purpose — it lives in the TEST source set so it can
+ * never be wired into the app. [failSeal]/[failOpen] drive the fail-closed paths.
+ */
+ private class ReversibleTestCipher(
+ private val failSeal: Boolean = false,
+ private val failOpen: Boolean = false,
+ val key: Byte = 0x2C,
+ ) : AuthCookieCipher {
+ override fun seal(plaintext: String): String {
+ if (failSeal) throw IllegalStateException("test cipher: keystore unavailable")
+ return java.util.Base64.getEncoder().encodeToString(plaintext.toByteArray().xored())
+ }
+
+ override fun open(sealed: String): String {
+ if (failOpen) throw IllegalStateException("test cipher: blob failed authentication")
+ return String(java.util.Base64.getDecoder().decode(sealed).xored())
+ }
+
+ private fun ByteArray.xored(): ByteArray = ByteArray(size) { (this[it].toInt() xor key.toInt()).toByte() }
+ }
+
+ private companion object {
+ /** Distinctive so a leak is unambiguous, and long enough that a partial match is meaningful. */
+ const val SECRET = "SUPER_SECRET_SHELL_TOKEN_9f3a7c11"
+ const val NOW = 1_700_000_000_000L
+ const val FAR_FUTURE = 4_000_000_000_000L
+
+ /** Every encoding a naive "protection" might leave the credential recoverable through. */
+ fun recoverableFormsOf(secret: String): List = listOf(
+ secret,
+ java.util.Base64.getEncoder().encodeToString(secret.toByteArray()),
+ java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(secret.toByteArray()),
+ secret.toByteArray().joinToString("") { "%02x".format(it) },
+ java.net.URLEncoder.encode(secret, "UTF-8"),
+ )
+ }
+}
diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStoreTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStoreTest.kt
new file mode 100644
index 0000000..e7ce8d9
--- /dev/null
+++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStoreTest.kt
@@ -0,0 +1,124 @@
+package wang.yaojia.webterm.hostregistry
+
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * The [AuthCookieStore] contract, exercised through the in-memory double that ships in `main`
+ * (it doubles for the DataStore store in the AW4 wiring tests, exactly like [InMemoryHostStore]).
+ */
+class InMemoryAuthCookieStoreTest {
+
+ private val hostA = "http://192.168.1.5:3000"
+ private val hostB = "https://tunnel.example:443"
+
+ private fun record(hostKey: String, name: String = "webterm_auth", value: String = "v") =
+ AuthCookieRecord(
+ hostKey = hostKey,
+ name = name,
+ value = value,
+ domain = "example",
+ path = "/",
+ expiresAtEpochMillis = FAR_FUTURE,
+ )
+
+ @Test
+ fun upsertReturnsTheNewListAndIsReadableBack() = runTest {
+ // Arrange
+ val store = InMemoryAuthCookieStore()
+
+ // Act
+ val afterFirst = store.upsert(record(hostA))
+ val afterSecond = store.upsert(record(hostB))
+
+ // Assert
+ assertEquals(1, afterFirst.size)
+ assertEquals(listOf(hostA, hostB), afterSecond.map { it.hostKey })
+ assertEquals(afterSecond, store.loadAll())
+ }
+
+ @Test
+ fun upsertReplacesTheSameRecordInPlace() = runTest {
+ // Arrange
+ val store = InMemoryAuthCookieStore(listOf(record(hostA, value = "old"), record(hostB)))
+
+ // Act
+ val updated = store.upsert(record(hostA, value = "new"))
+
+ // Assert
+ assertEquals(listOf(hostA, hostB), updated.map { it.hostKey })
+ assertEquals("new", updated.first().value)
+ }
+
+ @Test
+ fun loadForHostNeverLeaksAnotherHostsCookie() = runTest {
+ // Arrange
+ val store = InMemoryAuthCookieStore(listOf(record(hostA), record(hostB)))
+
+ // Act
+ val forA = store.loadForHost(hostA)
+
+ // Assert
+ assertEquals(1, forA.size)
+ assertTrue(forA.all { it.hostKey == hostA })
+ }
+
+ @Test
+ fun removeAnUnknownIdIsANoOp() = runTest {
+ // Arrange
+ val store = InMemoryAuthCookieStore(listOf(record(hostA)))
+
+ // Act
+ val updated = store.remove(record(hostB).id)
+
+ // Assert
+ assertEquals(1, updated.size)
+ assertEquals(updated, store.loadAll())
+ }
+
+ @Test
+ fun removeHostClearsEveryCookieForThatHostOnly() = runTest {
+ // Arrange
+ val store = InMemoryAuthCookieStore(
+ listOf(record(hostA), record(hostA, name = "second"), record(hostB)),
+ )
+
+ // Act
+ val updated = store.removeHost(hostA)
+
+ // Assert
+ assertEquals(listOf(hostB), updated.map { it.hostKey })
+ assertEquals(emptyList(), store.loadForHost(hostA))
+ }
+
+ @Test
+ fun replaceHostSwapsThatHostsCookiesWholesale() = runTest {
+ // Arrange: this is the write the OkHttp cookie jar drives — "here is host A's cookie set now".
+ val store = InMemoryAuthCookieStore(listOf(record(hostA, value = "stale"), record(hostB)))
+
+ // Act
+ val updated = store.replaceHost(hostA, listOf(record(hostA, value = "fresh")))
+
+ // Assert
+ assertEquals("fresh", updated.single { it.hostKey == hostA }.value)
+ assertEquals(1, updated.count { it.hostKey == hostB })
+ }
+
+ @Test
+ fun replaceHostWithAnEmptyListClearsThatHost() = runTest {
+ // Arrange: the server expired the cookie (Max-Age=0) → the jar reports an empty set.
+ val store = InMemoryAuthCookieStore(listOf(record(hostA), record(hostB)))
+
+ // Act
+ val updated = store.replaceHost(hostA, emptyList())
+
+ // Assert
+ assertEquals(listOf(hostB), updated.map { it.hostKey })
+ }
+
+ private companion object {
+ const val FAR_FUTURE = 4_000_000_000_000L
+ }
+}
diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt
new file mode 100644
index 0000000..93fcfe7
--- /dev/null
+++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt
@@ -0,0 +1,347 @@
+package wang.yaojia.webterm.terminalview
+
+import android.content.Context
+import android.util.TypedValue
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.ViewConfiguration
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import android.view.inputmethod.InputMethodManager
+import android.widget.FrameLayout
+import com.termux.terminal.TerminalEmulator
+import com.termux.view.TerminalView
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.roundToInt
+
+/**
+ * The `View` that actually goes into the Compose tree: a frame around the stock Termux [TerminalView],
+ * which keeps doing all the glyph rendering — and which this frame must also keep away from the three
+ * stock paths that dereference the `TerminalSession` this app deliberately does not have.
+ *
+ * ### Why a frame and not the stock view directly (plan §6.1 deviation, recorded)
+ * `com.termux.view.TerminalView` is `public final` — it cannot be subclassed — and its
+ * `onCreateInputConnection` (offsets 52-62) hard-wires the anonymous `TerminalView$2` connection whose
+ * text path dead-ends on the absent `TerminalSession`. `onCreateInputConnection` is called on the
+ * **focused** view and there is no interception hook, so owning the IME contract means owning a focusable
+ * view. This frame is that view: it takes focus, returns [RemoteTerminalInputConnection], and forwards
+ * hardware keys down into the stock view so the [WebTermTerminalViewClient] decision path still runs.
+ *
+ * ### What the frame must intercept, and why (all offsets from the pinned `terminal-view-v0.118.0.aar`)
+ * `mTermSession` is **null forever** here: `TerminalSession` is `final`, its constructor forks a local
+ * process over JNI, and a dummy instance would only move the NPE (`getEmulator()` returns null two
+ * instructions later). Every stock method that dereferences it must therefore be made unreachable:
+ *
+ * 1. **scroll** — `doScroll` offsets 56-79 call `handleKeyCode(19|20, 0)` while the alternate screen
+ * buffer is active, and `handleKeyCode` offsets 15-19 are `mTermSession.getEmulator()`, unguarded.
+ * Reached from `TerminalView$1.onScroll`, the `TerminalView$1$1` fling runnable and
+ * `onGenericMotionEvent`; no `TerminalViewClient` callback sits anywhere on that path.
+ * → [onInterceptTouchEvent] / [dispatchGenericMotionEvent] claim it; [TerminalScrollGesture] does it.
+ * 2. **autofill** — `autofill(AutofillValue)` offsets 7-20 are `mTermSession.write(...)`, and the view
+ * advertises itself as fillable (`getAutofillType()` = AUTOFILL_TYPE_TEXT, `getAutofillValue()`
+ * non-null), so any password manager filling it crashes the app. → the whole subtree is excluded from
+ * the autofill structure ([getImportantForAutofill]).
+ * 3. **keys** — `onKeyDown` offset 150 is `mTermSession.write(getCharacters())`.
+ * → [WebTermTerminalViewClient] consumes every non-system key before that, and answers false to
+ * `shouldBackButtonBeMappedToEscape` so BACK cannot enter the terminal branch either.
+ * 4. **text selection** — `startTextSelectionMode` arms an `ActionMode` whose Copy
+ * (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100) is
+ * `mTermSession.onCopyTextToClipboard(...)` and whose Paste (offsets 126-136) is
+ * `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true,
+ * which is a **recorded deviation from plan §6.5** (see that method for the full rationale).
+ *
+ * ### Stock-method audit (verified with `javap -p -c`; keep it current when the pinned version moves)
+ * | stock member | reachable here? | verdict |
+ * |---|---|---|
+ * | `onKeyDown` / `onKeyUp` / `onKeyPreIme` | yes | safe — a client is installed and consumes first |
+ * | `onCreateInputConnection` | no (frame owns focus) | client installed anyway |
+ * | `onCheckIsTextEditor`, `isOpaque`, `setTextSize`, `setTypeface` | yes | no session deref |
+ * | `onDraw`, `onScreenUpdated`, `computeVerticalScroll*` | yes | `mEmulator`/`mRenderer` guarded or built in [init] |
+ * | `getColumnAndRow`, `getCursorX/Y`, `getPointX/Y`, `getTopRow`, `setTopRow` | yes | no session deref |
+ * | `onTouchEvent` (mouse buttons: context menu, middle-click paste) | yes | `mEmulator`-guarded, no session deref |
+ * | `doScroll`, `onGenericMotionEvent`, `TerminalView$1.onScroll`, `TerminalView$1$1.run` | **no** | intercepted here (1) |
+ * | `handleKeyCode` | **no** | only `doScroll` called it |
+ * | `autofill`, `getAutofillType`, `getAutofillValue` | **no** | subtree excluded (2) |
+ * | `inputCodePoint` | no | its own `mTermSession == null` early return (offsets 59-66) |
+ * | `updateSize` | no | early-returns without a session; [TerminalResizeDriver] replaces it |
+ * | `attachSession` | no | never called — it is the process-forking path we replace |
+ * | `startTextSelectionMode` / `TextSelectionCursorController` | **no** | long press consumed (4) |
+ * | `setTerminalCursorBlinker*` | never invoked | cosmetic gap vs stock: the cursor does not blink |
+ *
+ * ### The latent renderer crash this also fixes
+ * `TerminalView.mRenderer` is only created by `setTextSize`, and nothing called it. `onDraw` dereferences
+ * `mRenderer` unconditionally once `mEmulator` is non-null (offsets 36-61), so the very first frame after
+ * binding our emulator would have thrown. [init] sets the text size, which both creates the renderer and
+ * is what makes the real cell metrics ([cellMetrics]) available for the resize path.
+ *
+ * DEVICE QA (plan §7): focus/IME behaviour across IMEs, CJK composition, link taps, scroll feel (slop,
+ * momentum) and the real font-metric grid are verified on a device — this class is glue, and the logic it
+ * delegates to ([TerminalKeyDecoder], [TerminalInputBytes], [TerminalResizeDriver], [TerminalScrollGesture])
+ * is what the JVM tests pin.
+ */
+public class RemoteTerminalHostView internal constructor(context: Context) : FrameLayout(context) {
+
+ /** The stock Termux view. Rendering is entirely its own; we only feed it an emulator and input. */
+ internal val stockView: TerminalView = TerminalView(context, null)
+
+ /** Set once by [RemoteTerminalView]; every input path routes through it. */
+ internal var sink: TerminalInputSink? = null
+
+ /** The event currently being dispatched — the mouse-report branch needs its coordinates + down-time. */
+ private var eventInFlight: MotionEvent? = null
+
+ /** Stock `mMouseScrollStartX/Y` + `mMouseStartDownTime`: one anchor cell per wheel gesture. */
+ private var wheelAnchorDownTime: Long = NO_DOWN_TIME
+ private var wheelAnchorColumn: Int = 0
+ private var wheelAnchorRow: Int = 0
+
+ private val scrollGesture = TerminalScrollGesture(
+ touchSlopPx = ViewConfiguration.get(context)?.scaledTouchSlop?.takeIf { it > 0 }
+ ?: FALLBACK_TOUCH_SLOP_PX,
+ cellHeightPx = { cellMetrics()?.lineSpacingPx ?: NO_CELL_HEIGHT },
+ target = object : TerminalScrollTarget {
+ override fun emulator(): TerminalEmulator? = stockView.mEmulator
+
+ override fun scrollTranscript(up: Boolean) {
+ val transcriptRows = stockView.mEmulator?.screen?.activeTranscriptRows ?: return
+ val step = if (up) -1 else 1
+ stockView.topRow = min(NEWEST_ROW, max(-transcriptRows, stockView.topRow + step))
+ stockView.invalidate()
+ }
+
+ override fun sendKeys(data: String) {
+ sink?.sendInput(data)
+ }
+
+ override fun reportMouseWheel(up: Boolean) = sendMouseWheel(up)
+ },
+ )
+
+ init {
+ addView(stockView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
+ // Builds TerminalRenderer → onDraw is safe and the cell metrics become readable.
+ stockView.setTextSize(defaultTextSizePx(context))
+ // The frame owns focus and therefore the IME; the stock view must not compete for it.
+ stockView.isFocusable = false
+ stockView.isFocusableInTouchMode = false
+ descendantFocusability = FOCUS_BLOCK_DESCENDANTS
+ isFocusable = true
+ isFocusableInTouchMode = true
+ // Belt and braces with getImportantForAutofill(): the setter is what a structure walk normally
+ // reads, the override is what a later setImportantForAutofill() cannot undo.
+ importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
+ stockView.importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
+ }
+
+ /** Install the client on the stock view. Idempotent; called as soon as the sink is known. */
+ internal fun installClient(client: com.termux.view.TerminalViewClient) {
+ stockView.setTerminalViewClient(client)
+ }
+
+ /** Point the stock renderer at our forked emulator (bypasses the process-forking `attachSession`). */
+ internal fun bindEmulator(emulator: TerminalEmulator) {
+ stockView.mEmulator = emulator
+ // A new emulator means a new transcript: drop any half-finished drag rather than let it resolve
+ // rows against a buffer it did not start in.
+ scrollGesture.reset()
+ }
+
+ /** Invalidate/redraw the stock view for the current emulator state (main thread). */
+ internal fun requestScreenUpdate() {
+ stockView.onScreenUpdated()
+ }
+
+ /**
+ * The renderer's real cell metrics, or null before the renderer exists. Read through
+ * `TerminalRenderer`'s **public** `getFontWidth()`/`getFontLineSpacing()` accessors — see
+ * [TerminalCellMetrics] for why that beats reflection or a re-measured `Paint`.
+ */
+ internal fun cellMetrics(): TerminalCellMetrics? {
+ val renderer = stockView.mRenderer ?: return null
+ return TerminalCellMetrics(renderer.fontWidth, renderer.fontLineSpacing)
+ }
+
+ /** The stock view's padding, which the grid maths subtracts (plan §6.4). */
+ internal fun horizontalPadding(): Int = stockView.paddingLeft
+ internal fun verticalPadding(): Int = stockView.paddingTop
+
+ /** Take focus and raise the soft keyboard — the response to a tap on the terminal. */
+ internal fun showSoftKeyboard() {
+ requestFocus()
+ val inputMethods = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+ inputMethods?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
+ }
+
+ // ── Autofill ─────────────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Keep the whole terminal subtree out of the autofill structure.
+ *
+ * `TerminalView.autofill(AutofillValue)` writes the filled text straight into a null `mTermSession`
+ * (offsets 7-20), and the view declares itself fillable, so a password manager offering to fill it is
+ * a crash. `View.isImportantForAutofill()` walks the parent chain calling this getter and stops at the
+ * first `*_EXCLUDE_DESCENDANTS`, so answering here covers the stock child as well. Overriding rather
+ * than only setting the flag also means no later `setImportantForAutofill` can quietly re-enable it.
+ *
+ * Nothing is lost: a terminal has no fillable fields, and shell input is exactly the content that
+ * should never enter an autofill structure (plan §8 — no secret leakage).
+ */
+ override fun getImportantForAutofill(): Int = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
+
+ // ── IME ──────────────────────────────────────────────────────────────────────────────────────────
+
+ override fun onCheckIsTextEditor(): Boolean = true
+
+ override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
+ val sink = this.sink ?: return null
+ // VISIBLE_PASSWORD + NO_SUGGESTIONS: no autocorrect, no predictive text and no learned-word
+ // history of what was typed into a shell. NO_EXTRACT_UI/NO_FULLSCREEN keep the landscape IME
+ // from covering the terminal with a full-screen text box.
+ outAttrs.inputType = EditorInfo.TYPE_CLASS_TEXT or
+ EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
+ EditorInfo.TYPE_TEXT_FLAG_NO_SUGGESTIONS
+ outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or
+ EditorInfo.IME_FLAG_NO_FULLSCREEN or
+ EditorInfo.IME_ACTION_NONE
+ return RemoteTerminalInputConnection(view = this, sink = sink, cursorModes = { sink.cursorModes() })
+ }
+
+ // ── Keys ─────────────────────────────────────────────────────────────────────────────────────────
+ // The frame holds focus, so key events land here first. Terminal keys are forwarded into the stock
+ // view so the WebTermTerminalViewClient decision path runs (that is where DECCKM and the byte
+ // encoding live); system keys skip it entirely.
+ //
+ // The skip is not just a shortcut: `TerminalView.onKeyDown` returns TRUE unconditionally while
+ // `mEmulator` is still null (offsets 62-70). Routing BACK through it would silently swallow the key
+ // during the window before the emulator binds — and forever if a session never binds — leaving the
+ // user stuck on the terminal screen. Answering system keys from here keeps that exit always open.
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean =
+ if (event.isSystem) super.onKeyDown(keyCode, event)
+ else stockView.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
+
+ override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean =
+ if (event.isSystem) super.onKeyUp(keyCode, event)
+ else stockView.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
+
+ /** Lets the stock view close an active text selection with BACK before the IME/Activity sees it. */
+ override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean =
+ stockView.onKeyPreIme(keyCode, event) || super.onKeyPreIme(keyCode, event)
+
+ // ── Touch & layout ───────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Claim focus on touch-down without consuming the gesture — taps, long presses and the mouse-button
+ * paths still reach the stock view.
+ */
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean {
+ if (event.actionMasked == MotionEvent.ACTION_DOWN && !isFocused) requestFocus()
+ return super.dispatchTouchEvent(event)
+ }
+
+ /**
+ * Take vertical drags away from the stock view.
+ *
+ * Answering true makes `ViewGroup` send the stock view `ACTION_CANCEL` and route the remainder of the
+ * gesture to [onTouchEvent] here — so `mGestureRecognizer` never observes a scroll, cannot call
+ * `TerminalView$1.onScroll` and cannot start the `TerminalView$1$1` fling, which is what makes the
+ * crashing `doScroll` unreachable rather than merely unlikely. Taps and pinches are never claimed.
+ */
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean = routeTouch(event)
+
+ /** The rest of a claimed drag. Returning true keeps the gesture here until it ends. */
+ override fun onTouchEvent(event: MotionEvent): Boolean =
+ routeTouch(event) || scrollGesture.isOwned || super.onTouchEvent(event)
+
+ /**
+ * The external-mouse wheel, the second stock entry into `doScroll` (offsets 43-55). `ViewGroup`
+ * offers generic pointer events to children first, so this override is the only place ahead of it.
+ */
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ if (event.isFromSource(InputDevice.SOURCE_MOUSE) && event.action == MotionEvent.ACTION_SCROLL) {
+ eventInFlight = event
+ try {
+ scrollGesture.onMouseWheel(scrollUp = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0f)
+ } finally {
+ eventInFlight = null
+ }
+ return true
+ }
+ return super.dispatchGenericMotionEvent(event)
+ }
+
+ override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight)
+ sink?.onViewSize(width, height)
+ }
+
+ private fun routeTouch(event: MotionEvent): Boolean {
+ eventInFlight = event
+ try {
+ return scrollGesture.onTouch(TouchFacts.of(event))
+ } finally {
+ eventInFlight = null
+ }
+ }
+
+ /**
+ * Stock `sendMouseEventCode(event, MOUSE_WHEEL{UP,DOWN}_BUTTON, true)`, offsets 0-100: report the
+ * wheel at the cell the gesture started on (one anchor per down-time) so a program tracking the mouse
+ * sees a stable position rather than one that drifts with the finger.
+ */
+ private fun sendMouseWheel(up: Boolean) {
+ val emulator = stockView.mEmulator ?: return
+ val event = eventInFlight ?: return
+ val cell = stockView.getColumnAndRow(event, false)
+ var column = cell[COLUMN_INDEX] + CELL_ORIGIN
+ var row = cell[ROW_INDEX] + CELL_ORIGIN
+ if (wheelAnchorDownTime == event.downTime) {
+ column = wheelAnchorColumn
+ row = wheelAnchorRow
+ } else {
+ wheelAnchorDownTime = event.downTime
+ wheelAnchorColumn = column
+ wheelAnchorRow = row
+ }
+ val button =
+ if (up) TerminalEmulator.MOUSE_WHEELUP_BUTTON else TerminalEmulator.MOUSE_WHEELDOWN_BUTTON
+ emulator.sendMouseEvent(button, column, row, true)
+ }
+
+ private companion object {
+ /** Terminal body text, in sp so it follows the system font scale. */
+ const val TEXT_SIZE_SP: Float = 12f
+
+ /** Guard rails so an extreme font scale cannot produce an unusable (or zero-width) cell. */
+ const val MIN_TEXT_SIZE_PX: Int = 8
+ const val MAX_TEXT_SIZE_PX: Int = 64
+
+ /**
+ * Used when `ViewConfiguration` cannot be resolved (or reports 0, which would make every touch a
+ * drag). The platform's own default `scaledTouchSlop` at mdpi, so behaviour degrades sensibly
+ * rather than turning taps into scrolls.
+ */
+ const val FALLBACK_TOUCH_SLOP_PX: Int = 8
+
+ /** The renderer has not measured a cell yet. */
+ const val NO_CELL_HEIGHT: Int = 0
+
+ /** Stock `mTopRow == 0` means "showing the live screen"; negative walks back into scrollback. */
+ const val NEWEST_ROW: Int = 0
+
+ /** No wheel gesture has been anchored yet (stock initialises `mMouseStartDownTime` to -1). */
+ const val NO_DOWN_TIME: Long = -1L
+
+ /** `getColumnAndRow` returns `[column, row]`, both 0-based; the wire protocol is 1-based. */
+ const val COLUMN_INDEX: Int = 0
+ const val ROW_INDEX: Int = 1
+ const val CELL_ORIGIN: Int = 1
+
+ fun defaultTextSizePx(context: Context): Int = TypedValue
+ .applyDimension(TypedValue.COMPLEX_UNIT_SP, TEXT_SIZE_SP, context.resources.displayMetrics)
+ .roundToInt()
+ .coerceIn(MIN_TEXT_SIZE_PX, MAX_TEXT_SIZE_PX)
+ }
+}
diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalInputConnection.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalInputConnection.kt
new file mode 100644
index 0000000..9312bd3
--- /dev/null
+++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalInputConnection.kt
@@ -0,0 +1,104 @@
+package wang.yaojia.webterm.terminalview
+
+import android.view.KeyEvent
+import android.view.View
+import android.view.inputmethod.BaseInputConnection
+
+/**
+ * BLOCKER 1, the soft-keyboard half — our own `InputConnection`, because the stock one cannot work
+ * without a `TerminalSession`.
+ *
+ * Traced in the pinned artifact (`TerminalView$2`, the anonymous `BaseInputConnection` returned by
+ * `TerminalView.onCreateInputConnection`):
+ * `commitText` → `sendTextToTerminal` → `TerminalView.inputCodePoint(...)`, and `inputCodePoint`
+ * **returns immediately at offset 59–66 when `mTermSession == null`**. So with our forked session the
+ * stock connection is not merely crash-prone, it is a black hole: everything typed on the soft keyboard
+ * would vanish with no error. (`deleteSurroundingText` is worse — it round-trips through
+ * `sendKeyEvent`, whose stock landing zone is the same dead path.)
+ *
+ * This replacement keeps the parts of `BaseInputConnection` that matter for composition (CJK/emoji IMEs
+ * build text in the editable before committing) and redirects the delivery to [TerminalInputSink], i.e.
+ * `RemoteTerminalSession.writeInput` → `engineSend(Input(..))`. `mTermSession` is not referenced at all.
+ *
+ * @param view the host view — `BaseInputConnection` needs it for the editable and for key dispatch.
+ */
+internal class RemoteTerminalInputConnection(
+ view: View,
+ private val sink: TerminalInputSink,
+ private val cursorModes: () -> TerminalCursorModes?,
+) : BaseInputConnection(view, /* fullEditor = */ true) {
+
+ /**
+ * The IME committed text (the normal typing path, and the end of a CJK composition). Mirrors
+ * Termux's order: let the base class fold the commit into the editable so composing state resolves
+ * correctly, then drain the editable to the wire and clear it — the terminal, not the editable, is
+ * the document.
+ */
+ override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
+ super.commitText(text, newCursorPosition)
+ drainToTerminal(fallback = text)
+ return true
+ }
+
+ /** Composition ended (IME switch, focus loss): flush whatever was staged rather than dropping it. */
+ override fun finishComposingText(): Boolean {
+ super.finishComposingText()
+ drainToTerminal(fallback = null)
+ return true
+ }
+
+ /**
+ * Backspace from the IME. Resolved through Termux's [com.termux.terminal.KeyHandler] (via the same
+ * decoder as a hardware key) so the byte matches what a physical Backspace sends — `DEL`, or `ESC
+ * DEL` under Alt — instead of a hand-rolled guess.
+ */
+ override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
+ repeat(beforeLength.coerceAtLeast(0)) { sendKeyCode(KeyEvent.KEYCODE_DEL) }
+ return super.deleteSurroundingText(beforeLength, afterLength)
+ }
+
+ /**
+ * Some IMEs deliver Enter/Backspace/arrows as key events rather than text. Handled here rather than
+ * dispatched into the view, so this path can never re-enter the stock terminal handling.
+ */
+ override fun sendKeyEvent(event: KeyEvent?): Boolean {
+ if (event == null) return false
+ if (event.action != KeyEvent.ACTION_DOWN) return true
+ sendKeyCode(event.keyCode, KeyEventFacts.of(event))
+ return true
+ }
+
+ private fun sendKeyCode(keyCode: Int, facts: KeyEventFacts = PLAIN_KEY) {
+ val modes = cursorModes()
+ val routing = TerminalKeyDecoder.decodeKeyDown(
+ keyCode = keyCode,
+ facts = facts,
+ appConsumed = false,
+ cursorKeysApplication = modes?.cursorKeysApplication ?: false,
+ keypadApplication = modes?.keypadApplication ?: false,
+ )
+ if (routing is KeyRouting.Send) sink.sendInput(routing.bytes)
+ }
+
+ private fun drainToTerminal(fallback: CharSequence?) {
+ val staged = editable
+ val content: CharSequence = if (staged != null && staged.isNotEmpty()) staged else fallback ?: return
+ val bytes = TerminalInputBytes.encodeText(content)
+ if (bytes.isNotEmpty()) sink.sendInput(bytes)
+ staged?.clear()
+ }
+
+ private companion object {
+ /** An unmodified, non-system key with no keymap character — the shape of an IME-synthesised key. */
+ val PLAIN_KEY = KeyEventFacts(
+ isSystemKey = false,
+ ctrlPressed = false,
+ altPressed = false,
+ shiftPressed = false,
+ numLockOn = false,
+ functionPressed = false,
+ metaState = 0,
+ unicodeChar = { 0 },
+ )
+ }
+}
diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt
index 981c19a..a5df859 100644
--- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt
+++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt
@@ -125,13 +125,20 @@ public class RemoteTerminalSession(
/**
* Latest-writer-wins resize (§6.4). Resolves the emulator's local buffer reflow AND emits a
- * `Resize` to the server (→ SIGWINCH) — but only when [dims] actually change from [lastSentDims]
- * and are positive. The dedup + `emulator.resize` are serialized on the confined thread so a resize
- * never races an append.
+ * `Resize` to the server (→ SIGWINCH) — normally only when the dims actually change from
+ * [lastSentDims] and are positive. The dedup + `emulator.resize` are serialized on the confined
+ * thread so a resize never races an append.
+ *
+ * @param force emit even when the dims are unchanged. This is what makes the §6.4 reclaim work: a
+ * shared PTY has ONE size, so a device coming back to the foreground has to re-assert dims it has
+ * already sent in order to take that size back from whichever device wrote last. Without this the
+ * re-assert is silently swallowed here and the returning device stays letterboxed. Reserved for
+ * that deliberate reclaim — ordinary layout churn must NOT set it, or every inset change becomes a
+ * wire frame and a SIGWINCH.
*/
- public fun updateSize(cols: Int, rows: Int) {
+ public fun updateSize(cols: Int, rows: Int, force: Boolean = false) {
if (cols < TerminalGridMath.MIN_DIMENSION || rows < TerminalGridMath.MIN_DIMENSION) return
- commands.trySend(TerminalCommand.Resize(cols, rows))
+ commands.trySend(TerminalCommand.Resize(cols, rows, force))
}
// ── Outbound: typed / key-bar bytes → wire ───────────────────────────────────────────────────────
@@ -198,7 +205,7 @@ public class RemoteTerminalSession(
try {
when (command) {
is TerminalCommand.Feed -> onFeed(command.bytes)
- is TerminalCommand.Resize -> onResize(command.cols, command.rows)
+ is TerminalCommand.Resize -> onResize(command.cols, command.rows, command.force)
is TerminalCommand.Bind -> onBind(command.publishEmulator, command.onScreenUpdated)
TerminalCommand.Unbind -> {
bound = false
@@ -242,12 +249,17 @@ public class RemoteTerminalSession(
}
}
- private fun onResize(cols: Int, rows: Int) {
+ private suspend fun onResize(cols: Int, rows: Int, force: Boolean) {
val next = TerminalGridSize(cols, rows)
- if (next == lastSentDims) return
+ // `force` is the §6.4 reclaim: unchanged dims must still reach the wire to win the shared PTY
+ // size back. Everything else is deduped here as the last line of defence against layout churn.
+ if (next == lastSentDims && !force) return
emulator.resize(cols, rows) // local buffer reflow (confined-thread single writer)
engineSend(ClientMessage.Resize(cols, rows)) // → server SIGWINCH
lastSentDims = next
+ // The reflow rewrote the on-screen cells; without a repaint the user keeps looking at the old
+ // wrap points until the next byte arrives (which, on an idle Claude session, can be minutes).
+ postScreenUpdate()
}
/**
@@ -302,7 +314,7 @@ public class RemoteTerminalSession(
/** Ordered commands to the single confined consumer — FIFO submission order == emulator write order. */
private sealed interface TerminalCommand {
class Feed(val bytes: ByteArray) : TerminalCommand
- class Resize(val cols: Int, val rows: Int) : TerminalCommand
+ class Resize(val cols: Int, val rows: Int, val force: Boolean = false) : TerminalCommand
class Bind(val publishEmulator: () -> Unit, val onScreenUpdated: () -> Unit) : TerminalCommand
data object Unbind : TerminalCommand
}
diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt
index cdba279..be43464 100644
--- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt
+++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt
@@ -3,54 +3,130 @@ package wang.yaojia.webterm.terminalview
import android.content.Context
import android.view.KeyEvent
import com.termux.terminal.TerminalEmulator
-import com.termux.view.TerminalView
/**
- * The on-screen terminal seam — a COMPOSITION wrapper around Termux's stock [TerminalView].
+ * The on-screen terminal seam — a COMPOSITION wrapper around Termux's stock `TerminalView`.
*
* ### Deviation from plan §6.1 (recorded): composition, not subclass
* §6.1 says "the Termux `TerminalView` is subclassed only to expose an onKeyCommand outlet + install
- * the key-bar." But at the pinned v0.118.0, `com.termux.view.TerminalView` is declared
- * `public final class` — it CANNOT be subclassed (verified: the Kotlin compiler rejects
- * `: TerminalView(...)` with "This type is final"). We therefore WRAP a stock instance and bind our
- * forked emulator through its PUBLIC `mEmulator` field instead of via `attachSession(TerminalSession)`
- * (there is no `TerminalSession` to fork — it too is `final`, and forking a process is exactly what we
- * must not do). Glyph rendering, cursor, selection, IME and scroll stay 100% stock — the wrapped view
- * renders unchanged; we only redirect where the emulator comes from and where input goes.
+ * the key-bar." At the pinned v0.118.0 `com.termux.view.TerminalView` is declared `public final class`,
+ * so it CANNOT be subclassed. We therefore wrap a stock instance inside a [RemoteTerminalHostView] and
+ * bind our forked emulator through its public `mEmulator` field instead of via
+ * `attachSession(TerminalSession)` (there is no `TerminalSession` to fork — it too is `final`, and
+ * forking a process is exactly what we must not do).
*
- * The [terminalView] is what A21 puts in the Compose tree (`AndroidView { remote.terminalView }`); the
- * key-bar / hardware-chord layer (A17) installs its handler through [onKeyCommand] (delivered via a
- * `TerminalViewClient` A17 sets on [terminalView]) and reads [onViewSizeChanged] for the resize path.
+ * **Glyph rendering and the cursor stay 100% stock. Scrolling and text selection do not** — both stock
+ * implementations dereference the absent `TerminalSession`, so [RemoteTerminalHostView] owns vertical
+ * drags and the long press is consumed ([WebTermTerminalViewClient.onLongPress], 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.
*
- * DEVICE-QA (plan §7): rendering, IME, selection, link taps, and the real font-metric→grid resize
- * (`TerminalRenderer.mFontWidth` is package-private → measured on-device and fed to [TerminalGridMath])
- * are verified on a device, not here. This class only has to compile and offer the binding seam.
+ * ### What this class owns
+ * It is the single place that knows which [RemoteTerminalSession] is bound, so it implements the
+ * [TerminalInputSink] every android-bound helper funnels into:
+ * - **keys** — [RemoteTerminalHostView] → stock `TerminalView` → [WebTermTerminalViewClient] →
+ * [TerminalKeyDecoder] → `writeInput`. Installing that client is what stops the guaranteed NPE on the
+ * first key press (`mClient` is dereferenced unguarded at `TerminalView.onKeyDown` offset 82–92).
+ * - **soft keyboard** — [RemoteTerminalHostView.onCreateInputConnection] →
+ * [RemoteTerminalInputConnection] → `writeInput`, replacing a stock connection that silently drops
+ * everything without a `TerminalSession`.
+ * - **resize** — [RemoteTerminalHostView.onSizeChanged] → [TerminalResizeDriver] →
+ * [RemoteTerminalSession.updateSize], which is the ONLY reason the server PTY ever learns the real
+ * grid: `TerminalView.updateSize()` early-returns without a session (offsets 18–25).
+ *
+ * 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).
*/
public class RemoteTerminalView(context: Context) {
- /** The stock, final Termux view. Added to the Compose tree by A21; rendering is entirely its own. */
- public val terminalView: TerminalView = TerminalView(context, null)
+ private val sink = object : TerminalInputSink {
+ override fun appConsumesKey(keyCode: Int, event: KeyEvent): Boolean =
+ onKeyCommand?.invoke(keyCode, event) == true
+
+ override fun cursorModes(): TerminalCursorModes? = session?.let {
+ TerminalCursorModes(
+ cursorKeysApplication = it.emulator.isCursorKeysApplicationMode,
+ keypadApplication = it.emulator.isKeypadApplicationMode,
+ )
+ }
+
+ override fun sendInput(data: String) {
+ session?.writeInput(data)
+ }
+
+ override fun onTapped() {
+ terminalView.showSoftKeyboard()
+ }
+
+ override fun onViewSize(widthPx: Int, heightPx: Int) {
+ resizeDriver.onViewSize(
+ widthPx = widthPx,
+ heightPx = heightPx,
+ horizontalPaddingPx = terminalView.horizontalPadding(),
+ verticalPaddingPx = terminalView.verticalPadding(),
+ )
+ // Keep the §6.4 latest-writer-wins outlet firing AFTER the grid is pushed, so :app's
+ // reclaim (`notifyForegrounded`) acts on dims the server has already been told about.
+ onViewSizeChanged?.invoke(widthPx, heightPx)
+ }
+ }
+
+ /**
+ * The `View` A21 puts in the Compose tree (`AndroidView { remote.terminalView }`). It is the frame
+ * described in [RemoteTerminalHostView], not the raw Termux view — owning focus is the only way to
+ * own the `InputConnection`, since the stock `TerminalView` is final.
+ */
+ public val terminalView: RemoteTerminalHostView = RemoteTerminalHostView(context)
+
+ // Declared before `session`, whose setter calls into it.
+ private val resizeDriver = TerminalResizeDriver(
+ cellMetrics = { terminalView.cellMetrics() },
+ applyGrid = { grid, isReassert ->
+ session?.updateSize(grid.cols, grid.rows, force = isReassert)
+ },
+ )
/** The forked session backing this view. Set by [RemoteTerminalSession.attachView]. */
public var session: RemoteTerminalSession? = null
+ set(value) {
+ field = value
+ // A new session means a new emulator at its default grid, so the grid this view already
+ // measured has to be pushed again even though the pixels never changed — a forced re-assert
+ // rather than a memo reset, so it survives the writer's own dedup too (§6.4).
+ resizeDriver.reassertLastGrid()
+ }
/**
- * Hardware-key outlet (A17 install point). A17 wires this into a `TerminalViewClient` set on
- * [terminalView]; returning true consumes the event, otherwise arrows/Enter/Tab should be resolved
- * via [RemoteTerminalSession.keyBytes] so DECCKM still emits `ESC O A` (plan §6.3).
+ * Hardware-key outlet (A17 install point). Returning true means `:app` handled AND already sent the
+ * key; returning false leaves it to Termux's `KeyHandler` so DECCKM still emits `ESC O A`
+ * (plan §6.3) — that fallback now runs inside [TerminalKeyDecoder] rather than inside the stock
+ * Termux path, which would have dereferenced the absent `TerminalSession`.
*/
public var onKeyCommand: ((keyCode: Int, event: KeyEvent) -> Boolean)? = null
- /** Layout-change outlet (A17/A21 install point) — wires real font metrics into the resize path. */
+ /** Layout-change outlet (A17/A21 install point) — the §6.4 device-switch reclaim hook. */
public var onViewSizeChanged: ((widthPx: Int, heightPx: Int) -> Unit)? = null
- /** Point the stock renderer at the forked emulator (bypasses the process-forking `attachSession`). */
+ 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))
+ }
+
+ /** Point the stock renderer at the forked emulator, then re-assert the measured grid on it. */
public fun bindEmulator(emulator: TerminalEmulator) {
- terminalView.mEmulator = emulator
+ terminalView.bindEmulator(emulator)
+ // The emulator starts at the server's 80x24 default; the view may already know better. Both calls
+ // are needed and they do not overlap: the re-assert covers "already measured" (and pushes from the
+ // memo, so it does not wait for a layout pass that may never come), while onViewSize covers the
+ // first bind, when there is nothing memoised yet. Each is a no-op in the other's case.
+ resizeDriver.reassertLastGrid()
+ sink.onViewSize(terminalView.width, terminalView.height)
}
/** Invalidate/redraw the stock view for the current emulator state (posted on the main thread). */
public fun requestScreenUpdate() {
- terminalView.onScreenUpdated()
+ terminalView.requestScreenUpdate()
}
}
diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytes.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytes.kt
new file mode 100644
index 0000000..89fe3bd
--- /dev/null
+++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytes.kt
@@ -0,0 +1,153 @@
+package wang.yaojia.webterm.terminalview
+
+import android.view.KeyCharacterMap
+import android.view.KeyEvent
+import com.termux.terminal.KeyHandler
+
+/**
+ * The pure byte arithmetic our forked session has to own, because the two stock Termux methods that
+ * normally do it both dead-end on the `TerminalSession` we do not have (plan §6.1 — no local process).
+ *
+ * Re-derived from the pinned artifact `terminal-view-v0.118.0.aar` / `terminal-emulator-v0.118.0.aar`:
+ * - `TerminalView.inputCodePoint(codePoint, ctrlDown, altDown)` — the Ctrl transform (offsets 148–318)
+ * and the dead-key aliases (offsets 319–374). It **early-returns at offset 59–66 when
+ * `mTermSession == null`**, which is permanently our case, so the whole path is dead for us.
+ * - `TerminalSession.writeCodePoint(prependEscape, codePoint)` — the `ESC` prefix for Alt and the UTF-8
+ * encode (offsets 45–272). It **throws `IllegalArgumentException`** for a surrogate/out-of-range code
+ * point (offsets 0–44); we return `null` instead, because a garbled key event must never take down
+ * the key path.
+ *
+ * Everything here is pure Kotlin over ints and strings — no view, no session, no android call at
+ * runtime (the `KeyEvent`/`KeyCharacterMap` constants are Java compile-time constants and inline).
+ * Bytes leave as a `String` because that is the wire shape (`ClientMessage.Input`) and the transport
+ * encodes UTF-8 once, at the boundary.
+ */
+internal object TerminalInputBytes {
+
+ /** `ESC` (0x1B) — the Alt prefix and the lead byte of every CSI/SS3 sequence. */
+ const val ESC: String = "\u001b"
+
+ /** `KeyCharacterMap.COMBINING_ACCENT` — set by `getUnicodeChar` when the key is a dead key. */
+ val COMBINING_ACCENT: Int = KeyCharacterMap.COMBINING_ACCENT
+
+ /** All Ctrl bits — stripped before asking the keymap for a printable char (Termux offset 357). */
+ val META_CTRL_MASK: Int = KeyEvent.META_CTRL_MASK
+
+ /** The Alt bits Termux also strips unless NumLock is on (offsets 362–375: `18`). */
+ val META_ALT_BITS: Int = KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON
+
+ /** The Shift bits Termux re-adds when shift is held (offsets 388–398: `65`). */
+ val META_SHIFT_BITS: Int = KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON
+
+ /** Unicode's largest code point; above it `writeCodePoint` throws. */
+ private const val MAX_CODE_POINT: Int = 0x10FFFF
+ private const val SURROGATE_FIRST: Int = 0xD800
+ private const val SURROGATE_LAST: Int = 0xDFFF
+
+ /** `LF`; a terminal Enter is `CR`, never `LF` (repo-wide gotcha, and Termux remaps it identically). */
+ private const val LINE_FEED: Int = 10
+ private const val CARRIAGE_RETURN: Int = 13
+
+ /** Ctrl-letter offsets: lowercase maps `a`→1 via `cp - 96`, uppercase `A`→1 via `cp - 64`. */
+ private const val CTRL_LOWERCASE_OFFSET: Int = 96
+ private const val CTRL_UPPERCASE_OFFSET: Int = 64
+
+ /**
+ * The Termux `KEYMOD_*` bitset for a key press — the second argument of [KeyHandler.getCode], which
+ * stays the single authority for the DECCKM/keypad key tables (plan §6.3).
+ */
+ fun keyModifiers(ctrl: Boolean, alt: Boolean, shift: Boolean, numLock: Boolean): Int {
+ var mod = 0
+ if (ctrl) mod = mod or KeyHandler.KEYMOD_CTRL
+ if (alt) mod = mod or KeyHandler.KEYMOD_ALT
+ if (shift) mod = mod or KeyHandler.KEYMOD_SHIFT
+ if (numLock) mod = mod or KeyHandler.KEYMOD_NUM_LOCK
+ return mod
+ }
+
+ /**
+ * The meta state to hand `KeyEvent.getUnicodeChar(...)`, mirroring `TerminalView.onKeyDown` offsets
+ * 357–418: drop the Ctrl bits (and the Alt bits unless NumLock is on) so the keymap yields the base
+ * character rather than nothing, then re-assert Shift so the character comes back capitalised.
+ */
+ fun effectiveMetaState(metaState: Int, shift: Boolean, numLockOn: Boolean): Int {
+ var bitsToClear = META_CTRL_MASK
+ if (!numLockOn) bitsToClear = bitsToClear or META_ALT_BITS
+ var effective = metaState and bitsToClear.inv()
+ if (shift) effective = effective or META_SHIFT_BITS
+ return effective
+ }
+
+ /**
+ * One key press → the exact bytes the shell should receive, or `null` when the code point is not
+ * representable (dropped rather than thrown).
+ *
+ * Order matters and matches Termux: the Ctrl transform runs first, then the dead-key aliases, then
+ * the optional `ESC` prefix for Alt.
+ */
+ fun encodeCodePoint(codePoint: Int, ctrlDown: Boolean, altDown: Boolean): String? {
+ if (codePoint < 0) return null
+ val controlled = if (ctrlDown) applyControl(codePoint) else codePoint
+ val resolved = applyDeadKeyAlias(controlled)
+ if (!isEncodable(resolved)) return null
+ val prefix = if (altDown) ESC else ""
+ return prefix + String(Character.toChars(resolved))
+ }
+
+ /**
+ * An IME commit (`commitText`/`finishComposingText`) → the bytes for the wire.
+ *
+ * This is the NET effect of Termux's `TerminalView$2.sendTextToTerminal`, which walks code points,
+ * remaps `LF`→`CR`, re-encodes C0 characters as "Ctrl + printable" and then feeds them back through
+ * the Ctrl transform — a round trip that provably returns the original C0 byte. Doing the net thing
+ * directly keeps one obvious implementation instead of two that must agree.
+ *
+ * Content is otherwise passed through verbatim (invariant #1/#9): CJK, emoji and surrogate pairs are
+ * never filtered, uppercased or normalised.
+ */
+ fun encodeText(text: CharSequence): String {
+ if (text.isEmpty()) return ""
+ val out = StringBuilder(text.length)
+ var index = 0
+ while (index < text.length) {
+ val codePoint = Character.codePointAt(text, index)
+ index += Character.charCount(codePoint)
+ out.appendCodePoint(if (codePoint == LINE_FEED) CARRIAGE_RETURN else codePoint)
+ }
+ return out.toString()
+ }
+
+ /** `TerminalView.inputCodePoint` offsets 148–318, alias for alias. */
+ private fun applyControl(codePoint: Int): Int = when (codePoint) {
+ in 'a'.code..'z'.code -> codePoint - CTRL_LOWERCASE_OFFSET
+ in 'A'.code..'Z'.code -> codePoint - CTRL_UPPERCASE_OFFSET
+ ' '.code, '2'.code -> 0
+ '['.code, '3'.code -> 27
+ '\\'.code, '4'.code -> 28
+ ']'.code, '5'.code -> 29
+ '^'.code, '6'.code -> 30
+ '_'.code, '7'.code, '/'.code -> 31
+ '8'.code -> 127
+ else -> codePoint
+ }
+
+ /** `TerminalView.inputCodePoint` offsets 319–374 — spacing modifier letters → their ASCII twins. */
+ private fun applyDeadKeyAlias(codePoint: Int): Int = when (codePoint) {
+ MODIFIER_CIRCUMFLEX -> '^'.code
+ MODIFIER_GRAVE -> '`'.code
+ MODIFIER_TILDE -> '~'.code
+ else -> codePoint
+ }
+
+ private fun isEncodable(codePoint: Int): Boolean =
+ codePoint in 0..MAX_CODE_POINT && codePoint !in SURROGATE_FIRST..SURROGATE_LAST
+
+ /** U+02C6 MODIFIER LETTER CIRCUMFLEX ACCENT. */
+ private const val MODIFIER_CIRCUMFLEX: Int = 710
+
+ /** U+02CB MODIFIER LETTER GRAVE ACCENT. */
+ private const val MODIFIER_GRAVE: Int = 715
+
+ /** U+02DC SMALL TILDE. */
+ private const val MODIFIER_TILDE: Int = 732
+}
diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt
new file mode 100644
index 0000000..0f0598b
--- /dev/null
+++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt
@@ -0,0 +1,37 @@
+package wang.yaojia.webterm.terminalview
+
+import android.view.KeyEvent
+
+/**
+ * The one outlet every input source in this module funnels into — hardware keys (via
+ * [WebTermTerminalViewClient]), the soft keyboard (via [RemoteTerminalInputConnection]) and layout
+ * changes (via [RemoteTerminalHostView]).
+ *
+ * It exists so the android-bound classes never reach for a `RemoteTerminalSession` (or, worse, Termux's
+ * `mTermSession`) directly: [RemoteTerminalView] is the only implementor and it is the single place
+ * where "which session is currently bound" is known. That keeps the §6.3 ordered-send model intact —
+ * everything ends at `RemoteTerminalSession.writeInput` → `engineSend(Input(..))`.
+ */
+internal interface TerminalInputSink {
+
+ /**
+ * `:app`'s hardware-chord outlet (`RemoteTerminalView.onKeyCommand` → `HardwareKeyRouter`). Returns
+ * true when `:app` both handled AND already sent the key, so the decoder must not send it again.
+ */
+ fun appConsumesKey(keyCode: Int, event: KeyEvent): Boolean
+
+ /** The emulator's live DECCKM / keypad bits, or null when no session is bound yet. */
+ fun cursorModes(): TerminalCursorModes?
+
+ /** Raw bytes onto the wire, verbatim (invariant #9 — never filtered). */
+ fun sendInput(data: String)
+
+ /** The user tapped the terminal: take focus and raise the soft keyboard. */
+ fun onTapped()
+
+ /** The host view was laid out at this pixel size (drives the §6.4 resize path). */
+ fun onViewSize(widthPx: Int, heightPx: Int)
+}
+
+/** The two emulator mode bits the Termux key tables need. Snapshotted so the decoder stays pure. */
+internal data class TerminalCursorModes(val cursorKeysApplication: Boolean, val keypadApplication: Boolean)
diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoder.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoder.kt
new file mode 100644
index 0000000..80fc899
--- /dev/null
+++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoder.kt
@@ -0,0 +1,131 @@
+package wang.yaojia.webterm.terminalview
+
+import android.view.KeyEvent
+import com.termux.terminal.KeyHandler
+
+/**
+ * What to do with one hardware key press.
+ *
+ * There is deliberately **no variant meaning "let the stock Termux path handle it"**. Every stock
+ * continuation past `TerminalViewClient.onKeyDown` dereferences `mTermSession`, which is null forever in
+ * this app (plan §6.1 — no local subprocess) and cannot be set (`TerminalSession` is `final` and its
+ * constructor builds a process I/O pipeline we must not have). Verified in `terminal-view-v0.118.0.aar`
+ * → `TerminalView.class`: `mTermSession.write(getCharacters())` at offset 149–160, `handleKeyCode` →
+ * `mTermSession.getEmulator()` at offset 15–19, `inputCodePoint` → `mTermSession.writeCodePoint` at
+ * offset 375–384. The absence of that variant is the fix: it makes the crash unrepresentable **on the key
+ * path**. It says nothing about the touch path, which bypasses `TerminalViewClient` entirely — see
+ * [RemoteTerminalHostView]'s stock-method audit for what guards that.
+ */
+internal sealed interface KeyRouting {
+
+ /** Consume the key and put exactly these bytes on the wire. */
+ data class Send(val bytes: String) : KeyRouting
+
+ /** Consume the key and send nothing (already sent by `:app`, or the key has no terminal meaning). */
+ data object Consumed : KeyRouting
+
+ /**
+ * Hand the key to the HOST view's `super` — i.e. the Activity's BACK/volume/system handling. Never
+ * to Termux's terminal path.
+ */
+ data object DeferToHostView : KeyRouting
+}
+
+/**
+ * The subset of an `android.view.KeyEvent` the decision needs, lifted out so the decision itself is pure
+ * and unit-testable without a device or a mocked framework class. [unicodeChar] is the keymap lookup
+ * (`KeyEvent.getUnicodeChar(metaState)`), deferred because the meta state it takes is itself derived.
+ */
+internal class KeyEventFacts(
+ val isSystemKey: Boolean,
+ val ctrlPressed: Boolean,
+ val altPressed: Boolean,
+ val shiftPressed: Boolean,
+ val numLockOn: Boolean,
+ val functionPressed: Boolean,
+ val metaState: Int,
+ val unicodeChar: (metaState: Int) -> Int,
+) {
+ companion object {
+ /** Read the facts off a real event. The only android-touching line in the key path. */
+ fun of(event: KeyEvent): KeyEventFacts = KeyEventFacts(
+ isSystemKey = event.isSystem,
+ ctrlPressed = event.isCtrlPressed,
+ altPressed = event.isAltPressed,
+ shiftPressed = event.isShiftPressed,
+ numLockOn = event.isNumLockOn,
+ functionPressed = event.isFunctionPressed,
+ metaState = event.metaState,
+ unicodeChar = { meta -> event.getUnicodeChar(meta) },
+ )
+ }
+}
+
+/**
+ * Turns a key press into a [KeyRouting], reproducing the useful half of `TerminalView.onKeyDown`
+ * (offsets 259–582) while routing every outcome away from `mTermSession`.
+ *
+ * Termux's [KeyHandler] stays the authority for the key tables, so DECCKM (`ESC O A` under
+ * application-cursor mode) and the keypad/modifier variants keep matching the web and iOS clients
+ * exactly — plan §6.3 forbids hand-rolling that table.
+ */
+internal object TerminalKeyDecoder {
+
+ /**
+ * @param appConsumed `:app`'s hardware-chord outlet already handled and SENT this key
+ * (`RemoteTerminalView.onKeyCommand` → `HardwareKeyRouter`); emitting again would double-type.
+ * @param cursorKeysApplication the emulator's live DECCKM bit.
+ * @param keypadApplication the emulator's live DECKPAM bit.
+ */
+ fun decodeKeyDown(
+ keyCode: Int,
+ facts: KeyEventFacts,
+ appConsumed: Boolean,
+ cursorKeysApplication: Boolean,
+ keypadApplication: Boolean,
+ ): KeyRouting {
+ if (appConsumed) return KeyRouting.Consumed
+ // BACK / HOME / volume must still reach the Activity, or the terminal screen becomes a trap.
+ if (facts.isSystemKey) return KeyRouting.DeferToHostView
+
+ // 1. The Termux key table (arrows, Enter, Tab, F-keys, Home/End, keypad) — DECCKM-correct.
+ // Gated on !Fn exactly like TerminalView.onKeyDown offsets 319-333.
+ if (!facts.functionPressed) {
+ val keyMod = TerminalInputBytes.keyModifiers(
+ ctrl = facts.ctrlPressed,
+ alt = facts.altPressed,
+ shift = facts.shiftPressed,
+ numLock = facts.numLockOn,
+ )
+ val mapped = KeyHandler.getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication)
+ if (!mapped.isNullOrEmpty()) return KeyRouting.Send(mapped)
+ }
+
+ // 2. Otherwise it is a printable key: ask the keymap, then apply the Ctrl/Alt transform.
+ val effectiveMeta = TerminalInputBytes.effectiveMetaState(
+ metaState = facts.metaState,
+ shift = facts.shiftPressed,
+ numLockOn = facts.numLockOn,
+ )
+ val raw = facts.unicodeChar(effectiveMeta)
+ if (raw == 0) return KeyRouting.Consumed
+ // A dead key (accent) — Termux composes it with the NEXT key using a package-private view field
+ // we cannot reach. Swallow it rather than emitting a lone spacing accent; real composition
+ // arrives through the IME path (RemoteTerminalInputConnection), which is the mobile text path.
+ if (raw and TerminalInputBytes.COMBINING_ACCENT != 0) return KeyRouting.Consumed
+
+ val bytes = TerminalInputBytes.encodeCodePoint(
+ codePoint = raw,
+ ctrlDown = facts.ctrlPressed,
+ altDown = facts.altPressed,
+ ) ?: return KeyRouting.Consumed
+ return KeyRouting.Send(bytes)
+ }
+
+ /**
+ * Key-up carries no bytes (the shell saw everything on key-down), so it is consumed — except for
+ * system keys, whose `up` must reach the Activity for BACK to fire.
+ */
+ fun decodeKeyUp(facts: KeyEventFacts): KeyRouting =
+ if (facts.isSystemKey) KeyRouting.DeferToHostView else KeyRouting.Consumed
+}
diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriver.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriver.kt
new file mode 100644
index 0000000..1a21588
--- /dev/null
+++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriver.kt
@@ -0,0 +1,86 @@
+package wang.yaojia.webterm.terminalview
+
+/**
+ * The real cell metrics of the stock Termux renderer.
+ *
+ * `TerminalRenderer.mFontWidth` / `mFontLineSpacing` are package-private fields, but the pinned
+ * artifact also ships **public accessors** — `public float getFontWidth()` and
+ * `public int getFontLineSpacing()` (verified with `javap -p` on
+ * `terminal-view-v0.118.0.aar` → `TerminalRenderer.class`). So the metrics are read through plain public
+ * API: no reflection (which would break under R8/a version bump silently), no same-package accessor
+ * shim, and no re-measured `Paint` (which would drift from whatever the renderer actually draws with —
+ * exactly the cols×rows drift plan risk R5 warns about).
+ */
+internal data class TerminalCellMetrics(val fontWidthPx: Float, val lineSpacingPx: Int)
+
+/**
+ * BLOCKER 2 — the thing that makes `resize` happen at all.
+ *
+ * Nothing in the app ever called `RemoteTerminalSession.updateSize`, and the stock fallback cannot
+ * help: `TerminalView.updateSize()` early-returns when `mTermSession == null` (offsets 18–25 of
+ * `TerminalView.class`), which is permanently our case. So the server PTY sat at the 80×24 it is created
+ * with and every full-screen TUI rendered into the wrong box.
+ *
+ * This driver is the replacement path: layout size + real cell metrics → the pure, already-tested
+ * [TerminalGridMath.computeGridSize] → [applyGrid]. It is deliberately **pure of android**: the view
+ * supplies the pixels and the metrics, so the arithmetic and the skip-logic are JVM-unit-testable.
+ *
+ * ### Why the dedup lives here as well as on the confined writer
+ * `RemoteTerminalSession` already refuses to re-emit an unchanged grid, but layout fires far more often
+ * than the grid changes (every scroll-inset, IME show/hide, and each frame of a fold/multi-window drag).
+ * Stopping at the source keeps that churn off the command channel entirely, so rotation costs exactly
+ * one `Resize` frame per real grid change. The dedup is on the GRID, not the pixel size: growing by a
+ * few sub-cell pixels is not a resize.
+ *
+ * ### …and why a re-assert has to be FLAGGED rather than just forgetting the memo
+ * Two dedups in series can cancel the §6.4 reclaim entirely. Clearing this memo only re-opens the
+ * *first* gate; the identical grid then dies on the writer's own `lastSentDims`, which survives the
+ * rebind, so the returning device never re-asserts its size on the wire and the other device keeps the
+ * shared PTY. [reassertLastGrid] therefore re-pushes with `isReassert = true`, which the writer honours
+ * as "emit even though nothing changed", and deliberately LEAVES the memo in place so ordinary layout
+ * churn still cannot ride along behind the reclaim.
+ *
+ * Not thread-safe by design — it is driven from `onSizeChanged`, i.e. the main thread only.
+ */
+internal class TerminalResizeDriver(
+ private val cellMetrics: () -> TerminalCellMetrics?,
+ private val applyGrid: (grid: TerminalGridSize, isReassert: Boolean) -> Unit,
+) {
+ private var lastGrid: TerminalGridSize? = null
+
+ /**
+ * A layout pass reported [widthPx]×[heightPx]. Resolves the grid and pushes it iff it is valid and
+ * actually new. Pre-layout (zero) sizes and a not-yet-built renderer are dropped silently — they are
+ * the normal startup order, not an error.
+ */
+ fun onViewSize(widthPx: Int, heightPx: Int, horizontalPaddingPx: Int, verticalPaddingPx: Int) {
+ val metrics = cellMetrics() ?: return
+ val grid = TerminalGridMath.computeGridSize(
+ viewWidthPx = widthPx,
+ viewHeightPx = heightPx,
+ horizontalPaddingPx = horizontalPaddingPx,
+ verticalPaddingPx = verticalPaddingPx,
+ fontWidthPx = metrics.fontWidthPx,
+ fontLineSpacingPx = metrics.lineSpacingPx,
+ ) ?: return
+ if (grid == lastGrid) return
+ lastGrid = grid
+ applyGrid(grid, false)
+ }
+
+ /**
+ * Re-push the last measured grid as a forced re-assert (§6.4 latest-writer-wins).
+ *
+ * Fires from a lifecycle / focus / pane-show callback, not from `onSizeChanged` — there is no new
+ * layout pass to piggyback on, so the push comes from the memo rather than from a re-measure. Before
+ * anything has been measured there is nothing to assert and this is a no-op: the first real layout
+ * will push anyway.
+ *
+ * The memo is intentionally NOT cleared — see the class doc. Clearing it would let the next identical
+ * layout pass push a second time.
+ */
+ fun reassertLastGrid() {
+ val grid = lastGrid ?: return
+ applyGrid(grid, true)
+ }
+}
diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGesture.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGesture.kt
new file mode 100644
index 0000000..9c18279
--- /dev/null
+++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGesture.kt
@@ -0,0 +1,202 @@
+package wang.yaojia.webterm.terminalview
+
+import android.view.KeyEvent
+import android.view.MotionEvent
+import com.termux.terminal.KeyHandler
+import com.termux.terminal.TerminalEmulator
+import kotlin.math.abs
+
+/**
+ * BLOCKER — the scroll path that had to be taken away from the stock view.
+ *
+ * `TerminalView.doScroll` (offsets 56-79 of the pinned `terminal-view-v0.118.0.aar`) turns a scroll into
+ * `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` **whenever the alternate screen buffer is active**, and
+ * `handleKeyCode` offsets 15-19 are `mTermSession.getEmulator()` with no null guard. `mEmulator` IS
+ * guarded (offset 4); `mTermSession` is not. Since this app has no `TerminalSession` at all (plan §6.1 —
+ * no local subprocess) that is a hard NPE on the UI thread, i.e. a process crash, for one finger-swipe
+ * inside vim / htop / less / man / the git-log pager / tmux — every TUI in the plan's own §7 device-QA
+ * list. `TerminalViewClient` has no callback anywhere on that path, so installing a client cannot help.
+ * Three stock entry points reach it: `TerminalView$1.onScroll` (offsets 103-110),
+ * `TerminalView$1$1.run` (the fling runnable, offset 120) and `onGenericMotionEvent` (offsets 43-55).
+ *
+ * The fix is ownership: [RemoteTerminalHostView] claims the vertical drag before the stock view sees it,
+ * so all three entry points become unreachable, and this class reproduces what `doScroll` *should* have
+ * done — the same three branches, in the same order, with the same arithmetic:
+ *
+ * | stock `doScroll` offsets | condition | this class |
+ * |--------------------------|-------------------------------|---------------------------------------------|
+ * | 26-53 | `isMouseTrackingActive()` | [TerminalScrollTarget.reportMouseWheel] |
+ * | 56-79 (**the crash**) | `isAlternateBufferActive()` | [TerminalScrollTarget.sendKeys], DECCKM-correct |
+ * | 86-130 | otherwise | [TerminalScrollTarget.scrollTranscript] |
+ *
+ * The mode bits are read off the **bound emulator** — which is the very object stock reaches for via
+ * `mTermSession.getEmulator()`, so the arrow form (CSI vs SS3) is identical, not re-derived. Termux's
+ * [KeyHandler] stays the authority for the key table (plan §6.3 forbids hand-rolling it).
+ *
+ * Pure of `android.view.View`: the pixels arrive as [TouchFacts] and the effects leave through
+ * [TerminalScrollTarget], so the whole decision is JVM-unit-testable — the same shape [KeyEventFacts]
+ * gives the key path.
+ */
+internal class TerminalScrollGesture(
+ private val touchSlopPx: Int,
+ private val cellHeightPx: () -> Int,
+ private val target: TerminalScrollTarget,
+) {
+ private var owned = false
+ private var lastY = 0f
+ private var travelSinceDownPx = 0f
+ private var unspentTravelPx = 0f
+
+ /** True once this gesture belongs to us; the stock view has been sent `ACTION_CANCEL` by then. */
+ val isOwned: Boolean get() = owned
+
+ /**
+ * Feed one touch sample.
+ *
+ * @return true once the gesture is ours. `ViewGroup` reads that from `onInterceptTouchEvent` as
+ * "cancel the child and route the rest here", which is exactly the guarantee we need: the stock
+ * `mGestureRecognizer` never sees a scroll, so it can neither call `doScroll` nor start a fling.
+ */
+ fun onTouch(facts: TouchFacts): Boolean = when (facts.phase) {
+ TouchPhase.DOWN -> {
+ reset(facts.y)
+ false
+ }
+
+ TouchPhase.MOVE -> onMove(facts)
+
+ TouchPhase.END -> {
+ val wasOwned = owned
+ reset(facts.y)
+ wasOwned
+ }
+
+ TouchPhase.OTHER -> owned
+ }
+
+ /**
+ * One external-mouse wheel notch, replacing stock `onGenericMotionEvent` offsets 43-55 (which hands
+ * `±MOUSE_WHEEL_ROWS` straight to the crashing `doScroll`).
+ */
+ fun onMouseWheel(scrollUp: Boolean) {
+ scrollRows(if (scrollUp) -MOUSE_WHEEL_ROWS else MOUSE_WHEEL_ROWS)
+ }
+
+ /** Drop any half-finished gesture (view detach / emulator rebind). */
+ fun reset() {
+ reset(lastY)
+ }
+
+ private fun reset(y: Float) {
+ owned = false
+ lastY = y
+ travelSinceDownPx = 0f
+ unspentTravelPx = 0f
+ }
+
+ private fun onMove(facts: TouchFacts): Boolean {
+ // A second finger means a pinch: that gesture belongs to the stock ScaleGestureDetector, which is
+ // safe (TerminalView$1.onScale touches no session) and is where our onScale veto is enforced.
+ if (facts.pointerCount > SINGLE_POINTER) return owned
+
+ // Positive == the finger moved UP the screen, matching GestureDetector's `distanceY`
+ // (`previousFocusY - currentFocusY`) so every sign below is stock's sign.
+ val travelPx = lastY - facts.y
+ lastY = facts.y
+
+ if (!owned) {
+ travelSinceDownPx += travelPx
+ if (abs(travelSinceDownPx) < touchSlopPx) return false
+ owned = true
+ // The slop travel is spent on the claim itself — GestureDetector likewise only starts
+ // reporting distance once the slop is crossed, so a claimed drag does not jump a row.
+ unspentTravelPx = 0f
+ return true
+ }
+
+ val cellHeight = cellHeightPx()
+ // Before the renderer has measured a cell there is no row size to divide by. Keep the gesture
+ // (dropping it now would hand the rest of the drag back to the crashing stock path) and scroll
+ // nothing — which is also what stock does with a not-yet-laid-out view.
+ if (cellHeight <= 0) return true
+
+ unspentTravelPx += travelPx
+ val rows = (unspentTravelPx / cellHeight).toInt()
+ unspentTravelPx -= rows * cellHeight
+ scrollRows(rows)
+ return true
+ }
+
+ /** `doScroll(event, rows)` — same branch order, same per-row loop, minus the `mTermSession` deref. */
+ private fun scrollRows(rows: Int) {
+ if (rows == 0) return
+ val emulator = target.emulator() ?: return
+ val up = rows < 0
+ repeat(abs(rows)) {
+ when {
+ emulator.isMouseTrackingActive -> target.reportMouseWheel(up)
+ emulator.isAlternateBufferActive -> arrowKeyBytes(emulator, up)?.let(target::sendKeys)
+ else -> target.scrollTranscript(up)
+ }
+ }
+ }
+
+ /**
+ * What stock `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` would have written — `KeyHandler.getCode` with
+ * the emulator's live DECCKM/keypad bits, so vim and htop still get `ESC O A` rather than `ESC [ A`.
+ */
+ private fun arrowKeyBytes(emulator: TerminalEmulator, up: Boolean): String? = KeyHandler.getCode(
+ if (up) KeyEvent.KEYCODE_DPAD_UP else KeyEvent.KEYCODE_DPAD_DOWN,
+ NO_KEY_MODIFIERS,
+ emulator.isCursorKeysApplicationMode,
+ emulator.isKeypadApplicationMode,
+ )
+
+ companion object {
+ /** Stock `onGenericMotionEvent` scrolls three rows per wheel notch. */
+ const val MOUSE_WHEEL_ROWS: Int = 3
+
+ /** `handleKeyCode` is called with a zero key-modifier bitset from `doScroll`. */
+ private const val NO_KEY_MODIFIERS: Int = 0
+
+ private const val SINGLE_POINTER: Int = 1
+ }
+}
+
+/** Which part of a gesture a sample belongs to. Cancel counts as an end: the gesture is over either way. */
+internal enum class TouchPhase { DOWN, MOVE, END, OTHER }
+
+/**
+ * The subset of a `MotionEvent` the scroll decision needs, lifted out so the decision stays pure and
+ * unit-testable without a device — the same trick [KeyEventFacts] plays for keys.
+ */
+internal data class TouchFacts(val phase: TouchPhase, val y: Float, val pointerCount: Int) {
+ companion object {
+ fun of(event: MotionEvent): TouchFacts = TouchFacts(
+ phase = when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> TouchPhase.DOWN
+ MotionEvent.ACTION_MOVE -> TouchPhase.MOVE
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> TouchPhase.END
+ else -> TouchPhase.OTHER
+ },
+ y = event.y,
+ pointerCount = event.pointerCount,
+ )
+ }
+}
+
+/** Where a resolved scroll lands. Implemented by [RemoteTerminalHostView] over the stock view. */
+internal interface TerminalScrollTarget {
+
+ /** The bound emulator, or null before bind — the live source of the mode bits `doScroll` reads. */
+ fun emulator(): TerminalEmulator?
+
+ /** Stock `doScroll` offsets 86-130: move the local transcript window by one row and repaint. */
+ fun scrollTranscript(up: Boolean)
+
+ /** Stock `doScroll` offsets 66-79, rewritten to reach the wire instead of a null `TerminalSession`. */
+ fun sendKeys(data: String)
+
+ /** Stock `doScroll` offsets 36-53 → `sendMouseEventCode(event, MOUSE_WHEEL*_BUTTON, true)`. */
+ fun reportMouseWheel(up: Boolean)
+}
diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt
new file mode 100644
index 0000000..0c77f7c
--- /dev/null
+++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt
@@ -0,0 +1,190 @@
+package wang.yaojia.webterm.terminalview
+
+import android.view.KeyEvent
+import android.view.MotionEvent
+import com.termux.terminal.TerminalSession
+import com.termux.view.TerminalView
+import com.termux.view.TerminalViewClient
+
+/**
+ * The `TerminalViewClient` that was never installed — the fix for the stock **key** paths only.
+ *
+ * `TerminalView.mClient` is dereferenced with **no null guard** on the first line of the paths a user
+ * actually hits (verified with `javap -p -c` on the pinned
+ * `terminal-view-v0.118.0.aar` → `TerminalView.class`):
+ * - `onCreateInputConnection` offset 0–4 → `mClient.isTerminalViewSelected()` — NPE the moment the soft
+ * keyboard attaches.
+ * - `onKeyDown` offset 82–92 → `mClient.onKeyDown(keyCode, event, mTermSession)` — NPE on the first key.
+ * - `onKeyUp` offset 64–70, `onKeyPreIme` offset 68–72, and the gesture callbacks — same shape.
+ *
+ * Installing any client removes the NPE; installing THIS one also keeps the crash from simply moving.
+ * A client that returned false would let the stock method continue into
+ * `mTermSession.write(getCharacters())` (offset 149–160), `handleKeyCode` →
+ * `mTermSession.getEmulator()` (offset 15–19, no guard) or `inputCodePoint` (which silently drops
+ * everything because of its own `mTermSession == null` early return at offset 59–66). `mTermSession` is
+ * null forever — `TerminalSession` is `final` and forking a process is exactly what this app must not do
+ * (plan §6.1). So this client **consumes the key itself** and returns true, which ends `onKeyDown` at
+ * `invalidate(); return true` (offset 97–105) before any of that is reachable. [TerminalKeyDecoder]
+ * encodes that as a type: it has no "hand it back to Termux" outcome.
+ *
+ * ### What this client does NOT cover (the correction that a review caught)
+ * A `TerminalViewClient` only sees the KEY paths. **Touch never passes through it**: `onTouchEvent` →
+ * `mGestureRecognizer` → `TerminalView$1.onScroll` → `doScroll` → `handleKeyCode` →
+ * `mTermSession.getEmulator()` has no client callback anywhere on it, so installing this client does
+ * nothing for the alternate-screen scroll crash. That one is fixed by ownership in
+ * [RemoteTerminalHostView], whose KDoc carries the full enumerated audit of which stock members are
+ * reachable and which are proven safe. Rendering and the cursor stay stock; scrolling and text selection
+ * do not (plan §6.1/§6.5 deviations, both recorded there and on [onLongPress]).
+ *
+
+ * @param sink the single outlet — `:app`'s chord router, the emulator's live cursor modes, and the byte
+ * send pump. See [TerminalInputSink].
+ */
+internal class WebTermTerminalViewClient(private val sink: TerminalInputSink) : TerminalViewClient {
+
+ // ── Keys ─────────────────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * @param session ALWAYS null here (see the class doc) and therefore ignored — it is only in the
+ * signature because Termux passes its own `mTermSession` through.
+ */
+ override fun onKeyDown(keyCode: Int, event: KeyEvent, session: TerminalSession?): Boolean {
+ val modes = sink.cursorModes()
+ val routing = TerminalKeyDecoder.decodeKeyDown(
+ keyCode = keyCode,
+ facts = KeyEventFacts.of(event),
+ appConsumed = sink.appConsumesKey(keyCode, event),
+ cursorKeysApplication = modes?.cursorKeysApplication ?: false,
+ keypadApplication = modes?.keypadApplication ?: false,
+ )
+ return consume(routing)
+ }
+
+ override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean =
+ consume(TerminalKeyDecoder.decodeKeyUp(KeyEventFacts.of(event)))
+
+ /**
+ * @param session ALWAYS null (unreachable in practice — `inputCodePoint` returns before calling us
+ * when `mTermSession` is null). Implemented anyway, returning true, so that even if a future edit
+ * made the path reachable it could still never fall through to `mTermSession.writeCodePoint`.
+ */
+ override fun onCodePoint(codePoint: Int, ctrlDown: Boolean, session: TerminalSession?): Boolean {
+ TerminalInputBytes.encodeCodePoint(codePoint, ctrlDown, altDown = false)?.let(sink::sendInput)
+ return true
+ }
+
+ private fun consume(routing: KeyRouting): Boolean = when (routing) {
+ is KeyRouting.Send -> {
+ sink.sendInput(routing.bytes)
+ true
+ }
+
+ KeyRouting.Consumed -> true
+ // false → the stock method falls through to `super.onKeyDown/onKeyUp` (offsets 130 / 91), i.e.
+ // plain View handling. It never reaches the terminal path, because we also answer false to
+ // shouldBackButtonBeMappedToEscape below.
+ KeyRouting.DeferToHostView -> false
+ }
+
+ /**
+ * MUST stay false. True would make `TerminalView.onKeyDown` route a system BACK press INTO the
+ * terminal branch (offset 113–136), whose next stop is `mTermSession.write(...)`.
+ */
+ override fun shouldBackButtonBeMappedToEscape(): Boolean = false
+
+ /** No latched modifier bar in this module — `:app`'s key-bar sends finished byte sequences instead. */
+ override fun readControlKey(): Boolean = false
+ override fun readAltKey(): Boolean = false
+ override fun readShiftKey(): Boolean = false
+ override fun readFnKey(): Boolean = false
+
+ /** Termux's Ctrl+Space quirk workaround is for its own extra-keys bar; not applicable. */
+ override fun shouldUseCtrlSpaceWorkaround(): Boolean = false
+
+ // ── IME hints ────────────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Only consulted by the stock `onCreateInputConnection`, which [RemoteTerminalHostView] overrides —
+ * but it must answer true so that if anything ever does reach the stock path it configures a text
+ * editor rather than declaring the view unselected.
+ */
+ override fun isTerminalViewSelected(): Boolean = true
+
+ /** Terminals want no autocorrect, no suggestions and no learned-word history of what you typed. */
+ override fun shouldEnforceCharBasedInput(): Boolean = true
+
+ // ── Gestures ─────────────────────────────────────────────────────────────────────────────────────
+
+ /** Tapping the terminal takes focus and raises the soft keyboard (Termux's own app does the same). */
+ override fun onSingleTapUp(event: MotionEvent?) = sink.onTapped()
+
+ /**
+ * Pinch-zoom is declined: the font size is what the grid maths divides by, so changing it mid-flight
+ * would need a resize round-trip on every pinch frame. Returning the identity scale pins
+ * [TerminalView]'s `mScaleFactor` at 1 and leaves the metrics stable.
+ */
+ override fun onScale(scale: Float): Float = NO_SCALE
+
+ /**
+ * MUST stay true — text selection is **cut**, a recorded deviation from plan §6.5.
+ *
+ * `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`:
+ * `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.
+ *
+ * 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.
+ */
+ /**
+ * 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 copyModeChanged(copyMode: Boolean) = Unit
+
+ /** Only fired from the stock `updateSize`, which is dead for us; our binding path is explicit. */
+ override fun onEmulatorSet() = Unit
+
+ // ── Logging: intentionally inert ─────────────────────────────────────────────────────────────────
+ // Termux funnels key codes, typed characters and IME text through these. Terminal content and
+ // keystrokes are exactly what must never reach logcat (plan §8, "untrusted server strings" / no
+ // secret leakage), so every one of them is a no-op — matching NoOpTerminalSessionClient.
+
+ override fun logError(tag: String?, message: String?) = Unit
+ override fun logWarn(tag: String?, message: String?) = Unit
+ override fun logInfo(tag: String?, message: String?) = Unit
+ override fun logDebug(tag: String?, message: String?) = Unit
+ override fun logVerbose(tag: String?, message: String?) = Unit
+ override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) = Unit
+ override fun logStackTrace(tag: String?, e: Exception?) = Unit
+
+ private companion object {
+ /** The identity scale factor — declines pinch-zoom without disturbing the renderer. */
+ const val NO_SCALE: Float = 1.0f
+ }
+}
diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostViewTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostViewTest.kt
new file mode 100644
index 0000000..4d29b6e
--- /dev/null
+++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostViewTest.kt
@@ -0,0 +1,207 @@
+package wang.yaojia.webterm.terminalview
+
+import android.view.View
+import io.mockk.unmockkAll
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_HEIGHT_PX
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_APPLICATION_CURSOR_KEYS
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_MOUSE_TRACKING
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.down
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.mouseWheel
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.move
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.up
+import com.termux.terminal.TerminalEmulator
+
+/**
+ * The host frame's ownership of scrolling and autofill — the two stock paths that would otherwise NPE
+ * (see [StockTerminalViewHazardTest] for the hazard itself, executed).
+ *
+ * The touch calls here mirror exactly what `ViewGroup.dispatchTouchEvent` does: `onInterceptTouchEvent`
+ * is consulted for every event while a child holds the touch target; the instant it answers **true** the
+ * child is sent `ACTION_CANCEL`, the target is dropped, and every later event of the gesture goes to the
+ * frame's own `onTouchEvent`. So "intercept returned true" is precisely the statement "the stock
+ * `TerminalView` will never see the rest of this drag" — which is what keeps `doScroll` unreachable.
+ */
+class RemoteTerminalHostViewTest {
+
+ private lateinit var host: RemoteTerminalHostView
+ private lateinit var sink: RecordingInputSink
+
+ @BeforeEach
+ fun setUp() {
+ // The renderer measures its cell box in the constructor, so this must precede the view.
+ StockTerminalViewFixture.mockCellMetrics()
+ host = RemoteTerminalHostView(StockTerminalViewFixture.context())
+ sink = RecordingInputSink()
+ host.sink = sink
+ }
+
+ @AfterEach fun tearDown() = unmockkAll()
+
+ private fun bind(vararg setupSequences: String): TerminalEmulator {
+ val emulator = StockTerminalViewFixture.emulator()
+ setupSequences.forEach { emulator.feed(it) }
+ host.bindEmulator(emulator)
+ return emulator
+ }
+
+ /** Drag the finger UP by [pixels] — the direction that scrolls toward newer output. */
+ private fun dragUp(pixels: Int) {
+ assertFalse(host.onInterceptTouchEvent(down(START_Y)), "a touch-down alone is not a scroll")
+ // The claim consumes the slop travel, exactly like GestureDetector.
+ assertTrue(host.onInterceptTouchEvent(move(START_Y - CLAIM_TRAVEL_PX)), "the drag must be claimed")
+ host.onTouchEvent(move(START_Y - CLAIM_TRAVEL_PX - pixels))
+ host.onTouchEvent(up(START_Y - CLAIM_TRAVEL_PX - pixels))
+ }
+
+ // ── Finding 1: the alternate-screen scroll crash ─────────────────────────────────────────────────
+
+ @Test
+ fun `a swipe inside an alternate-screen TUI emits arrow keys instead of reaching the stock scroll`() {
+ bind(ENTER_ALTERNATE_BUFFER)
+
+ dragUp(pixels = 2 * CELL_HEIGHT_PX)
+
+ // Stock would have run doScroll -> handleKeyCode -> mTermSession.getEmulator() -> NPE.
+ assertEquals(listOf("\u001b[B", "\u001b[B"), sink.sent, "two cells of travel == two DOWN arrows")
+ }
+
+ @Test
+ fun `the arrows follow the emulator's live DECCKM mode`() {
+ bind(ENTER_ALTERNATE_BUFFER, ENABLE_APPLICATION_CURSOR_KEYS)
+
+ dragUp(pixels = CELL_HEIGHT_PX)
+
+ assertEquals(listOf("\u001bOB"), sink.sent, "vim/htop in application-cursor mode need SS3")
+ }
+
+ @Test
+ fun `dragging the other way inside an alternate-screen TUI emits the opposite arrow`() {
+ bind(ENTER_ALTERNATE_BUFFER)
+
+ assertFalse(host.onInterceptTouchEvent(down(START_Y)))
+ assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX)))
+ host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + CELL_HEIGHT_PX))
+
+ assertEquals(listOf("\u001b[A"), sink.sent)
+ }
+
+ @Test
+ fun `an external mouse wheel inside an alternate-screen TUI also stays off the stock scroll path`() {
+ bind(ENTER_ALTERNATE_BUFFER)
+
+ // Stock onGenericMotionEvent offsets 43-55 hand a +-3 row scroll straight to doScroll.
+ assertTrue(host.dispatchGenericMotionEvent(mouseWheel(scrollUp = true)))
+
+ assertEquals(List(MOUSE_WHEEL_ROWS) { "\u001b[A" }, sink.sent)
+ }
+
+ @Test
+ fun `a two-finger gesture is left to the stock pinch detector`() {
+ bind(ENTER_ALTERNATE_BUFFER)
+
+ assertFalse(host.onInterceptTouchEvent(down(START_Y)))
+ assertFalse(
+ host.onInterceptTouchEvent(move(START_Y - CLAIM_TRAVEL_PX, pointers = 2)),
+ "claiming a pinch would kill zoom handling for a scroll we are not doing",
+ )
+ assertTrue(sink.sent.isEmpty())
+ }
+
+ // ── The two non-crashing stock branches must still behave like stock ─────────────────────────────
+
+ @Test
+ fun `on the normal buffer the drag scrolls the local transcript and sends nothing`() {
+ val emulator = bind()
+ repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") }
+
+ // Drag DOWN — the direction that walks back into scrollback.
+ assertFalse(host.onInterceptTouchEvent(down(START_Y)))
+ assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX)))
+ host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + 3 * CELL_HEIGHT_PX))
+
+ assertEquals(-3, host.stockView.topRow, "three cells of travel == three transcript rows")
+ assertTrue(sink.sent.isEmpty(), "scrollback is local; the shell must not see arrow keys")
+ }
+
+ @Test
+ fun `transcript scrolling stops at the ends of the buffer`() {
+ val emulator = bind()
+ repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") }
+ val transcriptRows = emulator.screen.activeTranscriptRows
+
+ assertFalse(host.onInterceptTouchEvent(down(START_Y)))
+ assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX)))
+ host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + OVERSCROLL_CELLS * CELL_HEIGHT_PX))
+
+ assertEquals(-transcriptRows, host.stockView.topRow, "cannot scroll past the oldest retained row")
+ }
+
+ @Test
+ fun `a mouse-reporting TUI still gets wheel buttons, not arrows`() {
+ val emulator = bind(ENTER_ALTERNATE_BUFFER, ENABLE_MOUSE_TRACKING)
+ assertTrue(emulator.isMouseTrackingActive)
+
+ dragUp(pixels = CELL_HEIGHT_PX)
+
+ // Stock doScroll offsets 26-53 prefer the mouse report over both other branches; the emulator
+ // turns it into the wire bytes itself, so nothing reaches the key sink.
+ assertTrue(sink.sent.isEmpty(), "a mouse-tracking program reads wheel reports, not cursor keys")
+ }
+
+ @Test
+ fun `a drag shorter than the touch slop is left to the stock view`() {
+ bind(ENTER_ALTERNATE_BUFFER)
+
+ assertFalse(host.onInterceptTouchEvent(down(START_Y)))
+ assertFalse(
+ host.onInterceptTouchEvent(move(START_Y - 1f)),
+ "a one-pixel wobble is a tap; stealing it would break tap-to-focus",
+ )
+ assertTrue(sink.sent.isEmpty())
+ }
+
+ @Test
+ fun `nothing is emitted before an emulator is bound`() {
+ dragUp(pixels = 4 * CELL_HEIGHT_PX)
+
+ assertTrue(sink.sent.isEmpty())
+ }
+
+ // ── Finding 2: autofill ─────────────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `the terminal subtree is excluded from the autofill structure`() {
+ // TerminalView.autofill() writes straight into a null mTermSession (StockTerminalViewHazardTest),
+ // and it advertises itself as fillable, so the only fix is to keep it out of the structure the
+ // autofill service walks. `View.isImportantForAutofill()` walks the parent chain calling
+ // getImportantForAutofill() and stops at the first *_EXCLUDE_DESCENDANTS, so the frame answering
+ // this covers the stock child — which is why the frame OVERRIDES the getter rather than only
+ // setting the flag (a setter can be undone; an override cannot).
+ assertEquals(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS, host.importantForAutofill)
+ }
+
+ private companion object {
+ /** Where a drag starts, far enough from 0 that a downward drag stays positive. */
+ const val START_Y: Float = 400f
+
+ /** Comfortably past any plausible touch slop, so the claim is unambiguous. */
+ const val CLAIM_TRAVEL_PX: Float = 100f
+
+ /** Stock `onGenericMotionEvent` scrolls three rows per wheel notch. */
+ const val MOUSE_WHEEL_ROWS: Int = 3
+
+ /** Enough output to push rows off the top so there is a transcript to scroll into. */
+ const val SCROLLED_OFF_LINES: Int = 40
+
+ /** More cells than the transcript holds, to prove the clamp. */
+ const val OVERSCROLL_CELLS: Int = 500
+ }
+}
diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionResizeTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionResizeTest.kt
new file mode 100644
index 0000000..bb023c8
--- /dev/null
+++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionResizeTest.kt
@@ -0,0 +1,162 @@
+package wang.yaojia.webterm.terminalview
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import wang.yaojia.webterm.wire.ClientMessage
+
+/**
+ * BLOCKER 2, the session half: a resize is an emulator MUTATION, so §6.2's single-WRITER model requires
+ * it to travel the same confined `Channel` as the output feed — never a direct call
+ * from the layout thread. These tests pin both the wire behaviour (one `Resize` per real change) and the
+ * ordering (a resize submitted before a feed is applied to the buffer before that feed).
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class RemoteTerminalSessionResizeTest {
+
+ private fun sentResizes(sent: List) = sent.filterIsInstance()
+
+ @Test
+ fun `updateSize reflows the emulator and emits one Resize frame`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val sent = mutableListOf()
+ val session = RemoteTerminalSession(
+ engineSend = { sent += it },
+ appendDispatcher = dispatcher,
+ mainDispatcher = dispatcher,
+ )
+ session.bind {}
+
+ session.updateSize(cols = 100, rows = 40)
+ advanceUntilIdle()
+
+ assertEquals(listOf(ClientMessage.Resize(100, 40)), sentResizes(sent))
+ assertEquals(100, session.emulator.mColumns, "the local buffer must reflow to match the server")
+ assertEquals(40, session.emulator.mRows)
+
+ session.close()
+ advanceUntilIdle()
+ }
+
+ @Test
+ fun `an identical resize is skipped so the socket is not spammed`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val sent = mutableListOf()
+ val session = RemoteTerminalSession(
+ engineSend = { sent += it },
+ appendDispatcher = dispatcher,
+ mainDispatcher = dispatcher,
+ )
+ session.bind {}
+
+ session.updateSize(100, 40)
+ session.updateSize(100, 40)
+ session.updateSize(100, 40)
+ advanceUntilIdle()
+
+ assertEquals(1, sentResizes(sent).size)
+ assertEquals(TerminalGridSize(100, 40), session.lastSentDimsForTest())
+
+ session.close()
+ advanceUntilIdle()
+ }
+
+ @Test
+ fun `a non-positive dimension never reaches the wire`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val sent = mutableListOf()
+ val session = RemoteTerminalSession(
+ engineSend = { sent += it },
+ appendDispatcher = dispatcher,
+ mainDispatcher = dispatcher,
+ )
+ session.bind {}
+
+ session.updateSize(0, 40)
+ session.updateSize(80, 0)
+ session.updateSize(-5, -5)
+ advanceUntilIdle()
+
+ assertTrue(sentResizes(sent).isEmpty(), "server validates 1..1000; never send a bogus grid")
+
+ session.close()
+ advanceUntilIdle()
+ }
+
+ @Test
+ fun `a resize submitted before a feed is applied to the buffer first`() = runTest {
+ // The ordering guarantee of the confined writer: if the resize were applied off-channel (or
+ // after), these 7 characters would lay out against the OLD 80-column grid and the wrap point
+ // would be wrong — exactly the class of bug the single-writer model exists to prevent.
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val session = RemoteTerminalSession(
+ engineSend = {},
+ appendDispatcher = dispatcher,
+ mainDispatcher = dispatcher,
+ )
+ session.bind {}
+
+ session.updateSize(cols = 5, rows = 24)
+ session.feedRemote("ABCDEFG".toByteArray(Charsets.UTF_8))
+ advanceUntilIdle()
+
+ assertEquals(5, session.emulator.mColumns)
+ val text = session.screenTextForTest()
+ assertTrue(text.contains("ABCDE"), "expected a wrap at column 5; got: '${text.trim()}'")
+ assertTrue(text.contains("FG"), "the overflow must land on the next row; got: '${text.trim()}'")
+
+ session.close()
+ advanceUntilIdle()
+ }
+
+ @Test
+ fun `a resize repaints the view because the local buffer reflowed`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val session = RemoteTerminalSession(
+ engineSend = {},
+ appendDispatcher = dispatcher,
+ mainDispatcher = dispatcher,
+ )
+ var updates = 0
+ session.bind { updates++ }
+ advanceUntilIdle()
+ val afterBind = updates
+
+ session.updateSize(100, 40)
+ advanceUntilIdle()
+
+ assertTrue(updates > afterBind, "a reflow that is never repainted leaves a stale screen")
+
+ session.close()
+ advanceUntilIdle()
+ }
+
+ @Test
+ fun `a resize before bind still reaches the wire and is applied in order`() = runTest {
+ // The first layout pass can beat the AndroidView bind; the server must still learn the real grid.
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val sent = mutableListOf()
+ val session = RemoteTerminalSession(
+ engineSend = { sent += it },
+ appendDispatcher = dispatcher,
+ mainDispatcher = dispatcher,
+ )
+
+ session.updateSize(5, 24)
+ session.feedRemote("ABCDEFG".toByteArray(Charsets.UTF_8))
+ advanceUntilIdle()
+ assertEquals(listOf(ClientMessage.Resize(5, 24)), sentResizes(sent))
+
+ session.bind {}
+ advanceUntilIdle()
+
+ assertTrue(session.screenTextForTest().contains("ABCDE"))
+
+ session.close()
+ advanceUntilIdle()
+ }
+}
diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt
new file mode 100644
index 0000000..b3ee39c
--- /dev/null
+++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt
@@ -0,0 +1,138 @@
+package wang.yaojia.webterm.terminalview
+
+import android.content.Context
+import android.content.res.Resources
+import android.graphics.Paint
+import android.util.DisplayMetrics
+import android.view.InputDevice
+import android.view.MotionEvent
+import android.view.accessibility.AccessibilityManager
+import com.termux.terminal.TerminalEmulator
+import com.termux.terminal.TerminalOutput
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkConstructor
+
+/**
+ * Headless fixtures for driving the **real** Termux `TerminalView` / [RemoteTerminalHostView] on the JVM.
+ *
+ * The AGP mockable `android.jar` (`isReturnDefaultValues = true`) strips every framework method body, so
+ * the views construct and their own bytecode runs for real — which is exactly what these tests need: the
+ * stock crash paths and our guards are the real compiled code, not a re-description of it. Two framework
+ * pieces do need help:
+ * - `Context.getSystemService("accessibility")` — the stock `TerminalView` constructor calls
+ * `isEnabled()` on the result, so a default `null` would NPE before the view exists.
+ * - `Paint` — `TerminalRenderer` measures its cell box through it, and default `0f` metrics would make
+ * every cell zero-height. [mockCellMetrics] pins a [CELL_WIDTH_PX]×[CELL_HEIGHT_PX] cell so the
+ * px→row arithmetic is exercised with real numbers.
+ */
+internal object StockTerminalViewFixture {
+
+ /** The pinned cell box these tests measure scroll distances against. */
+ const val CELL_WIDTH_PX: Float = 12f
+ const val CELL_HEIGHT_PX: Int = 24
+
+ /** Termux's `TerminalRenderer` derives line spacing from `Paint.getFontSpacing()`. */
+ private const val FONT_ASCENT_PX: Float = -18f
+
+ /** A context complete enough for `TerminalView`'s constructor and our sp→px text sizing. */
+ fun context(): Context {
+ val resources = mockk(relaxed = true)
+ every { resources.displayMetrics } returns DisplayMetrics()
+ val context = mockk(relaxed = true)
+ every { context.resources } returns resources
+ every { context.getSystemService("accessibility") } returns mockk(relaxed = true)
+ return context
+ }
+
+ /** Must be called BEFORE building a view — the renderer measures its cell in the constructor. */
+ fun mockCellMetrics() {
+ mockkConstructor(Paint::class)
+ every { anyConstructed().fontSpacing } returns CELL_HEIGHT_PX.toFloat()
+ every { anyConstructed().ascent() } returns FONT_ASCENT_PX
+ every { anyConstructed().measureText(any()) } returns CELL_WIDTH_PX
+ }
+
+ /** A real emulator with no subprocess — the same shape `RemoteTerminalSession` binds. */
+ fun emulator(cols: Int = 80, rows: Int = 24, transcriptRows: Int = 1_000): TerminalEmulator =
+ TerminalEmulator(SilentTerminalOutput(), cols, rows, transcriptRows, NoOpTerminalSessionClient())
+
+ /** Feed raw bytes the way the WS output path does. */
+ fun TerminalEmulator.feed(text: String) {
+ val bytes = text.toByteArray()
+ append(bytes, bytes.size)
+ }
+
+ /** `ESC[?1049h` — the alternate screen buffer every full-screen TUI (vim/htop/less/man) enters. */
+ const val ENTER_ALTERNATE_BUFFER: String = "\u001b[?1049h"
+
+ /** `ESC[?1h` — DECCKM application-cursor mode, which switches arrows from CSI to SS3. */
+ const val ENABLE_APPLICATION_CURSOR_KEYS: String = "\u001b[?1h"
+
+ /** `ESC[?1000h` — X11 mouse reporting, the branch stock `doScroll` prefers over everything else. */
+ const val ENABLE_MOUSE_TRACKING: String = "\u001b[?1000h"
+
+ fun down(y: Float, x: Float = 0f): MotionEvent = touch(MotionEvent.ACTION_DOWN, x, y)
+ fun move(y: Float, x: Float = 0f, pointers: Int = 1): MotionEvent =
+ touch(MotionEvent.ACTION_MOVE, x, y, pointers)
+
+ fun up(y: Float, x: Float = 0f): MotionEvent = touch(MotionEvent.ACTION_UP, x, y)
+
+ private fun touch(action: Int, x: Float, y: Float, pointers: Int = 1): MotionEvent {
+ val event = mockk(relaxed = true)
+ every { event.action } returns action
+ every { event.actionMasked } returns action
+ every { event.x } returns x
+ every { event.y } returns y
+ every { event.pointerCount } returns pointers
+ every { event.downTime } returns TOUCH_DOWN_TIME
+ every { event.isFromSource(any()) } returns false
+ return event
+ }
+
+ /** An external-mouse wheel notch — the second stock entry into `doScroll`. */
+ fun mouseWheel(scrollUp: Boolean): MotionEvent {
+ val event = mockk(relaxed = true)
+ every { event.action } returns MotionEvent.ACTION_SCROLL
+ every { event.actionMasked } returns MotionEvent.ACTION_SCROLL
+ every { event.isFromSource(InputDevice.SOURCE_MOUSE) } returns true
+ every { event.getAxisValue(MotionEvent.AXIS_VSCROLL) } returns if (scrollUp) 1f else -1f
+ every { event.x } returns 0f
+ every { event.y } returns 0f
+ every { event.downTime } returns TOUCH_DOWN_TIME
+ return event
+ }
+
+ /** One gesture == one down-time, which is what stock `sendMouseEventCode` anchors the wheel cell on. */
+ private const val TOUCH_DOWN_TIME: Long = 1_000L
+
+ private class SilentTerminalOutput : TerminalOutput() {
+ override fun write(data: ByteArray?, offset: Int, count: Int) = Unit
+ override fun titleChanged(oldTitle: String?, newTitle: String?) = Unit
+ override fun onCopyTextToClipboard(text: String?) = Unit
+ override fun onPasteTextFromClipboard() = Unit
+ override fun onBell() = Unit
+ override fun onColorsChanged() = Unit
+ }
+}
+
+/** Captures everything the host view routes out, so a test can assert exact bytes. */
+internal class RecordingInputSink : TerminalInputSink {
+ val sent = mutableListOf()
+ var taps = 0
+ val sizes = mutableListOf>()
+
+ override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false
+ override fun cursorModes(): TerminalCursorModes? = null
+ override fun sendInput(data: String) {
+ sent += data
+ }
+
+ override fun onTapped() {
+ taps++
+ }
+
+ override fun onViewSize(widthPx: Int, heightPx: Int) {
+ sizes += widthPx to heightPx
+ }
+}
diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewHazardTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewHazardTest.kt
new file mode 100644
index 0000000..732db7a
--- /dev/null
+++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewHazardTest.kt
@@ -0,0 +1,74 @@
+package wang.yaojia.webterm.terminalview
+
+import android.view.KeyEvent
+import android.view.autofill.AutofillValue
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed
+import com.termux.view.TerminalView
+
+/**
+ * The two stock `TerminalView` methods that dereference `mTermSession` with **no null guard**, executed
+ * for real so the hazard is a measured fact rather than a bytecode reading.
+ *
+ * `mTermSession` is null forever in this app: `TerminalSession` is `final`, its constructor forks a local
+ * process over JNI, and having no local process is the whole point (plan §6.1). A dummy session does not
+ * help either — `getEmulator()` would return null and NPE on the very next instruction.
+ *
+ * These tests do not change behaviour; they LOCK the reason [RemoteTerminalHostView] must keep both paths
+ * unreachable. If a future Termux bump adds the missing guards, they fail — which is the signal to
+ * re-audit and simplify, not to delete them.
+ */
+class StockTerminalViewHazardTest {
+
+ @AfterEach fun tearDown() = unmockkAll()
+
+ /**
+ * `doScroll` offsets 56-79: while the ALTERNATE screen buffer is active it turns a scroll into
+ * `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)`, whose offsets 15-19 are `mTermSession.getEmulator()`
+ * with no guard. `mEmulator` IS guarded at offset 4; `mTermSession` is not — that asymmetry is the
+ * whole bug. Any swipe inside vim/htop/less/man/tmux takes this path.
+ */
+ @Test
+ fun `stock scrolling on an alternate-screen buffer dereferences the absent TerminalSession`() {
+ StockTerminalViewFixture.mockCellMetrics()
+ val view = TerminalView(StockTerminalViewFixture.context(), null)
+ view.setTextSize(TEXT_SIZE_PX)
+ val emulator = StockTerminalViewFixture.emulator()
+ emulator.feed(ENTER_ALTERNATE_BUFFER)
+ view.mEmulator = emulator
+
+ assertTrue(emulator.isAlternateBufferActive, "the fixture must reproduce the crashing state")
+ assertThrows(NullPointerException::class.java) {
+ view.handleKeyCode(KeyEvent.KEYCODE_DPAD_UP, NO_KEY_MODIFIERS)
+ }
+ }
+
+ /**
+ * `autofill(AutofillValue)` offsets 7-20: `mTermSession.write(value.getTextValue().toString())`, again
+ * unguarded — and the view advertises itself as fillable (`getAutofillType()` = AUTOFILL_TYPE_TEXT,
+ * `getAutofillValue()` non-null), so any autofill service may call it. [RemoteTerminalHostView]
+ * answers by excluding the whole subtree from the autofill structure.
+ */
+ @Test
+ fun `stock autofill dereferences the absent TerminalSession`() {
+ StockTerminalViewFixture.mockCellMetrics()
+ val view = TerminalView(StockTerminalViewFixture.context(), null)
+ val filled = mockk()
+ every { filled.isText } returns true
+ every { filled.textValue } returns "a-password-manager-suggestion"
+
+ assertThrows(NullPointerException::class.java) { view.autofill(filled) }
+ }
+
+ private companion object {
+ const val NO_KEY_MODIFIERS: Int = 0
+ const val TEXT_SIZE_PX: Int = 12
+ }
+}
diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytesTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytesTest.kt
new file mode 100644
index 0000000..67fee22
--- /dev/null
+++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytesTest.kt
@@ -0,0 +1,130 @@
+package wang.yaojia.webterm.terminalview
+
+import com.termux.terminal.KeyHandler
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Test
+
+/**
+ * [TerminalInputBytes] is the byte-for-byte re-derivation of the two Termux paths our client has to
+ * take over, because both of them dead-end on the `TerminalSession` we do not have:
+ *
+ * - `TerminalView.inputCodePoint(codePoint, ctrl, alt)` — the control-key + dead-key transform
+ * (verified against `terminal-view-v0.118.0.aar` → `TerminalView.class` offsets 148–374), which then
+ * calls `mTermSession.writeCodePoint(...)`. `inputCodePoint` early-returns at offset 59–66 when
+ * `mTermSession == null`, so with our forked session the whole IME text path is silently DROPPED.
+ * - `TerminalSession.writeCodePoint(prependEscape, codePoint)` (offsets 0–125) — an optional `ESC`
+ * prefix for Alt, UTF-8 encoding, and an `IllegalArgumentException` for an invalid code point (we
+ * return null instead — a hostile/garbled key must never throw into the key path).
+ *
+ * These assertions are the regression lock: if the transform drifts, Ctrl-C stops interrupting Claude.
+ */
+class TerminalInputBytesTest {
+
+ @Test
+ fun `control transform maps letters to their C0 codes`() {
+ // Ctrl-C is THE key of this app (interrupt Claude): 'c' (99) -> 0x03.
+ assertEquals("\u0003", TerminalInputBytes.encodeCodePoint('c'.code, ctrlDown = true, altDown = false))
+ assertEquals("\u0003", TerminalInputBytes.encodeCodePoint('C'.code, ctrlDown = true, altDown = false))
+ assertEquals("\u0001", TerminalInputBytes.encodeCodePoint('a'.code, ctrlDown = true, altDown = false))
+ assertEquals("\u001a", TerminalInputBytes.encodeCodePoint('z'.code, ctrlDown = true, altDown = false))
+ }
+
+ @Test
+ fun `control transform maps the punctuation and digit aliases`() {
+ // Offsets 197-318 of TerminalView.inputCodePoint, alias-for-alias.
+ assertEquals("\u0000", TerminalInputBytes.encodeCodePoint(' '.code, ctrlDown = true, altDown = false))
+ assertEquals("\u0000", TerminalInputBytes.encodeCodePoint('2'.code, ctrlDown = true, altDown = false))
+ assertEquals("\u001b", TerminalInputBytes.encodeCodePoint('['.code, ctrlDown = true, altDown = false))
+ assertEquals("\u001b", TerminalInputBytes.encodeCodePoint('3'.code, ctrlDown = true, altDown = false))
+ assertEquals("\u001c", TerminalInputBytes.encodeCodePoint('\\'.code, ctrlDown = true, altDown = false))
+ assertEquals("\u001d", TerminalInputBytes.encodeCodePoint(']'.code, ctrlDown = true, altDown = false))
+ assertEquals("\u001e", TerminalInputBytes.encodeCodePoint('^'.code, ctrlDown = true, altDown = false))
+ assertEquals("\u001f", TerminalInputBytes.encodeCodePoint('_'.code, ctrlDown = true, altDown = false))
+ assertEquals("\u001f", TerminalInputBytes.encodeCodePoint('/'.code, ctrlDown = true, altDown = false))
+ assertEquals("\u007f", TerminalInputBytes.encodeCodePoint('8'.code, ctrlDown = true, altDown = false))
+ }
+
+ @Test
+ fun `an unmapped code point is unchanged by the control transform`() {
+ assertEquals("!", TerminalInputBytes.encodeCodePoint('!'.code, ctrlDown = true, altDown = false))
+ }
+
+ @Test
+ fun `alt prepends ESC, matching writeCodePoint's prependEscape`() {
+ assertEquals("\u001bx", TerminalInputBytes.encodeCodePoint('x'.code, ctrlDown = false, altDown = true))
+ // Alt+Ctrl composes: ESC then the C0 code.
+ assertEquals("\u001b\u0003", TerminalInputBytes.encodeCodePoint('c'.code, ctrlDown = true, altDown = true))
+ }
+
+ @Test
+ fun `dead-key aliases map to their ASCII equivalents after the control transform`() {
+ // TerminalView.inputCodePoint offsets 319-374: 710 -> '^', 715 -> '`', 732 -> '~'.
+ assertEquals("^", TerminalInputBytes.encodeCodePoint(710, ctrlDown = false, altDown = false))
+ assertEquals("`", TerminalInputBytes.encodeCodePoint(715, ctrlDown = false, altDown = false))
+ assertEquals("~", TerminalInputBytes.encodeCodePoint(732, ctrlDown = false, altDown = false))
+ }
+
+ @Test
+ fun `an invalid code point yields null instead of throwing`() {
+ // TerminalSession.writeCodePoint THROWS IllegalArgumentException here (offsets 0-44). A garbled
+ // key event must never take down the key path, so we drop it.
+ assertNull(TerminalInputBytes.encodeCodePoint(0x110000, ctrlDown = false, altDown = false))
+ assertNull(TerminalInputBytes.encodeCodePoint(0xD800, ctrlDown = false, altDown = false))
+ assertNull(TerminalInputBytes.encodeCodePoint(0xDFFF, ctrlDown = false, altDown = false))
+ assertNull(TerminalInputBytes.encodeCodePoint(-1, ctrlDown = false, altDown = false))
+ }
+
+ @Test
+ fun `a non-BMP code point round-trips as a surrogate pair`() {
+ val emoji = 0x1F600
+ assertEquals(String(Character.toChars(emoji)), TerminalInputBytes.encodeCodePoint(emoji, false, false))
+ }
+
+ @Test
+ fun `encodeText maps LF to CR and preserves everything else`() {
+ // The repo-wide gotcha: Enter is \r (0x0D), never \n. Termux's sendTextToTerminal does the same
+ // remap (TerminalView$2.sendTextToTerminal offsets 113-122) before inputCodePoint.
+ assertEquals("ls -la\r", TerminalInputBytes.encodeText("ls -la\n"))
+ assertEquals("hi\r", TerminalInputBytes.encodeText("hi\r"))
+ assertEquals("", TerminalInputBytes.encodeText(""))
+ }
+
+ @Test
+ fun `encodeText preserves CJK and surrogate pairs verbatim`() {
+ // Invariant #9/#1: input bytes pass through unfiltered — an IME commit of CJK or an emoji must
+ // arrive at the shell byte-identical.
+ assertEquals("你好", TerminalInputBytes.encodeText("你好"))
+ val emoji = String(Character.toChars(0x1F600))
+ assertEquals(emoji, TerminalInputBytes.encodeText(emoji))
+ }
+
+ @Test
+ fun `keyModifiers builds the Termux KEYMOD bitset`() {
+ assertEquals(0, TerminalInputBytes.keyModifiers(false, false, false, false))
+ assertEquals(KeyHandler.KEYMOD_CTRL, TerminalInputBytes.keyModifiers(true, false, false, false))
+ assertEquals(KeyHandler.KEYMOD_ALT, TerminalInputBytes.keyModifiers(false, true, false, false))
+ assertEquals(KeyHandler.KEYMOD_SHIFT, TerminalInputBytes.keyModifiers(false, false, true, false))
+ assertEquals(KeyHandler.KEYMOD_NUM_LOCK, TerminalInputBytes.keyModifiers(false, false, false, true))
+ assertEquals(
+ KeyHandler.KEYMOD_CTRL or KeyHandler.KEYMOD_SHIFT,
+ TerminalInputBytes.keyModifiers(ctrl = true, alt = false, shift = true, numLock = false),
+ )
+ }
+
+ @Test
+ fun `effectiveMetaState clears the ctrl bits and re-adds shift`() {
+ // TerminalView.onKeyDown offsets 357-418: bitsToClear = META_CTRL_MASK (+ the ALT bits unless
+ // NumLock), then shift re-adds META_SHIFT_ON|META_SHIFT_LEFT_ON (65).
+ val ctrlAndAlt = TerminalInputBytes.META_CTRL_MASK or TerminalInputBytes.META_ALT_BITS
+ assertEquals(0, TerminalInputBytes.effectiveMetaState(ctrlAndAlt, shift = false, numLockOn = false))
+ assertEquals(
+ TerminalInputBytes.META_ALT_BITS,
+ TerminalInputBytes.effectiveMetaState(ctrlAndAlt, shift = false, numLockOn = true),
+ )
+ assertEquals(
+ TerminalInputBytes.META_SHIFT_BITS,
+ TerminalInputBytes.effectiveMetaState(ctrlAndAlt, shift = true, numLockOn = false),
+ )
+ }
+}
diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoderTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoderTest.kt
new file mode 100644
index 0000000..1d9c401
--- /dev/null
+++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoderTest.kt
@@ -0,0 +1,193 @@
+package wang.yaojia.webterm.terminalview
+
+import android.view.KeyEvent
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * BLOCKER 1 — the decision table that keeps every stock Termux path that dereferences `mTermSession`
+ * UNREACHABLE.
+ *
+ * Verified against `terminal-view-v0.118.0.aar` → `TerminalView.class`:
+ * - `onKeyDown` offset 82–92 calls `mClient.onKeyDown(keyCode, event, mTermSession)` with **no null
+ * guard on `mClient`** → today's guaranteed NPE on the first key press. Offsets 97–105 show the only
+ * escape hatch: a client that returns **true** ends the method at `invalidate(); return true`.
+ * - Every path past that point touches the session we do not have: offset 149–160
+ * `mTermSession.write(event.getCharacters())` (ACTION_MULTIPLE), offset 326 → `handleKeyCode` whose
+ * offsets 15–19 do `mTermSession.getEmulator()` with no guard, and offset 558 → `inputCodePoint`
+ * which dead-ends at its own `mTermSession == null` early return (offset 59–66).
+ *
+ * So [KeyRouting] deliberately has **no variant that hands the key back to the stock terminal path**:
+ * the only non-consuming outcome is [KeyRouting.DeferToHostView], which we implement as
+ * `FrameLayout.super.onKeyDown` — the Activity's BACK/volume handling, never Termux's.
+ */
+class TerminalKeyDecoderTest {
+
+ /** Facts with everything off — the shape of a plain, unmodified key press. */
+ private fun facts(
+ isSystemKey: Boolean = false,
+ ctrl: Boolean = false,
+ alt: Boolean = false,
+ shift: Boolean = false,
+ numLock: Boolean = false,
+ function: Boolean = false,
+ metaState: Int = 0,
+ unicodeChar: (Int) -> Int = { 0 },
+ ) = KeyEventFacts(isSystemKey, ctrl, alt, shift, numLock, function, metaState, unicodeChar)
+
+ // ── The mTermSession-unreachability invariant ────────────────────────────────────────────────────
+
+ @Test
+ fun `KeyRouting has no branch that defers to the stock terminal path`() {
+ // A structural lock on the design constraint. The `when` below is EXHAUSTIVE over the sealed
+ // hierarchy, so adding a fourth outcome — notably a "let Termux handle it" one, which would put
+ // mTermSession back on the reachable path — fails compilation here and cannot land unnoticed.
+ val outcomes = listOf(KeyRouting.Send("x"), KeyRouting.Consumed, KeyRouting.DeferToHostView)
+ val labels = outcomes.map { routing ->
+ when (routing) {
+ is KeyRouting.Send -> "bytes onto the wire"
+ KeyRouting.Consumed -> "swallowed"
+ KeyRouting.DeferToHostView -> "host view super (Activity BACK/volume)"
+ }
+ }
+ assertEquals(3, labels.toSet().size, "the three outcomes must stay distinct")
+ }
+
+ @Test
+ fun `an unmapped, unprintable key is consumed rather than handed back to Termux`() {
+ // e.g. a bare CTRL_LEFT: KeyHandler has no code and getUnicodeChar is 0. Stock would return false
+ // and fall through toward mTermSession; we consume instead.
+ val routing = TerminalKeyDecoder.decodeKeyDown(
+ keyCode = KeyEvent.KEYCODE_CTRL_LEFT,
+ facts = facts(),
+ appConsumed = false,
+ cursorKeysApplication = false,
+ keypadApplication = false,
+ )
+ assertEquals(KeyRouting.Consumed, routing)
+ }
+
+ // ── DECCKM (plan §6.3) — the authority stays Termux's KeyHandler ─────────────────────────────────
+
+ @Test
+ fun `arrows resolve through KeyHandler and differ between normal and application-cursor mode`() {
+ val normal = TerminalKeyDecoder.decodeKeyDown(
+ KeyEvent.KEYCODE_DPAD_UP, facts(), appConsumed = false,
+ cursorKeysApplication = false, keypadApplication = false,
+ )
+ val application = TerminalKeyDecoder.decodeKeyDown(
+ KeyEvent.KEYCODE_DPAD_UP, facts(), appConsumed = false,
+ cursorKeysApplication = true, keypadApplication = false,
+ )
+ assertEquals(KeyRouting.Send("\u001b[A"), normal, "DECCKM off must emit CSI")
+ assertEquals(KeyRouting.Send("\u001bOA"), application, "DECCKM on must emit SS3, never a hardcoded CSI")
+ assertTrue(normal != application, "the two cursor modes must not collapse to one byte string")
+ }
+
+ @Test
+ fun `Enter emits CR and Tab emits HT, shift-Tab emits CSI Z`() {
+ assertEquals(
+ KeyRouting.Send("\r"),
+ TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_ENTER, facts(), false, false, false),
+ "Enter must send \\r (0x0D), never \\n",
+ )
+ assertEquals(
+ KeyRouting.Send("\t"),
+ TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_TAB, facts(), false, false, false),
+ )
+ assertEquals(
+ KeyRouting.Send("\u001b[Z"),
+ TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_TAB, facts(shift = true), false, false, false),
+ )
+ }
+
+ @Test
+ fun `Backspace emits DEL`() {
+ assertEquals(
+ KeyRouting.Send("\u007f"),
+ TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_DEL, facts(), false, false, false),
+ )
+ }
+
+ // ── The app outlet (:app's HardwareKeyRouter via RemoteTerminalView.onKeyCommand) ────────────────
+
+ @Test
+ fun `an app-consumed key emits nothing more but is still consumed`() {
+ // :app already sent the bytes through onKeyCommand; emitting again would double-type.
+ val routing = TerminalKeyDecoder.decodeKeyDown(
+ KeyEvent.KEYCODE_ESCAPE, facts(), appConsumed = true,
+ cursorKeysApplication = false, keypadApplication = false,
+ )
+ assertEquals(KeyRouting.Consumed, routing)
+ }
+
+ // ── System keys keep working (BACK must still leave the screen) ──────────────────────────────────
+
+ @Test
+ fun `a system key defers to the host view so BACK still navigates`() {
+ val routing = TerminalKeyDecoder.decodeKeyDown(
+ KeyEvent.KEYCODE_BACK, facts(isSystemKey = true), appConsumed = false,
+ cursorKeysApplication = false, keypadApplication = false,
+ )
+ assertEquals(KeyRouting.DeferToHostView, routing)
+ }
+
+ @Test
+ fun `key-up defers only for system keys and consumes everything else`() {
+ assertEquals(KeyRouting.DeferToHostView, TerminalKeyDecoder.decodeKeyUp(facts(isSystemKey = true)))
+ assertEquals(KeyRouting.Consumed, TerminalKeyDecoder.decodeKeyUp(facts()))
+ }
+
+ // ── Printable keys: the unicode path replaces the dead inputCodePoint path ───────────────────────
+
+ @Test
+ fun `a printable key with no KeyHandler mapping sends its unicode char`() {
+ val routing = TerminalKeyDecoder.decodeKeyDown(
+ KeyEvent.KEYCODE_A, facts(unicodeChar = { 'a'.code }), appConsumed = false,
+ cursorKeysApplication = false, keypadApplication = false,
+ )
+ assertEquals(KeyRouting.Send("a"), routing)
+ }
+
+ @Test
+ fun `Ctrl-C resolves to the interrupt byte`() {
+ val routing = TerminalKeyDecoder.decodeKeyDown(
+ KeyEvent.KEYCODE_C, facts(ctrl = true, unicodeChar = { 'c'.code }), appConsumed = false,
+ cursorKeysApplication = false, keypadApplication = false,
+ )
+ assertEquals(KeyRouting.Send("\u0003"), routing)
+ }
+
+ @Test
+ fun `Alt-x resolves to ESC + x`() {
+ val routing = TerminalKeyDecoder.decodeKeyDown(
+ KeyEvent.KEYCODE_X, facts(alt = true, unicodeChar = { 'x'.code }), appConsumed = false,
+ cursorKeysApplication = false, keypadApplication = false,
+ )
+ assertEquals(KeyRouting.Send("\u001bx"), routing)
+ }
+
+ @Test
+ fun `a dead-key combining accent is swallowed rather than emitting garbage`() {
+ // getUnicodeChar sets KeyCharacterMap.COMBINING_ACCENT (0x80000000) for a dead key. Termux runs a
+ // mCombiningAccent state machine on a package-private view field we cannot reach; we consume the
+ // key instead so no garbled byte reaches the shell (composition is device-QA / IME-path work).
+ val routing = TerminalKeyDecoder.decodeKeyDown(
+ KeyEvent.KEYCODE_GRAVE,
+ facts(unicodeChar = { TerminalInputBytes.COMBINING_ACCENT or '`'.code }),
+ appConsumed = false, cursorKeysApplication = false, keypadApplication = false,
+ )
+ assertEquals(KeyRouting.Consumed, routing)
+ }
+
+ @Test
+ fun `a function-pressed key skips the KeyHandler table like Termux does`() {
+ // TerminalView.onKeyDown offset 319-333 gates handleKeyCode on !event.isFunctionPressed().
+ val routing = TerminalKeyDecoder.decodeKeyDown(
+ KeyEvent.KEYCODE_DPAD_UP, facts(function = true, unicodeChar = { 0 }), appConsumed = false,
+ cursorKeysApplication = false, keypadApplication = false,
+ )
+ assertEquals(KeyRouting.Consumed, routing, "Fn+Up must not emit the plain arrow sequence")
+ }
+}
diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriverTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriverTest.kt
new file mode 100644
index 0000000..1d088c3
--- /dev/null
+++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriverTest.kt
@@ -0,0 +1,183 @@
+package wang.yaojia.webterm.terminalview
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * BLOCKER 2 — nothing ever drove a `resize`, so the server PTY stayed at its 80×24 attach default
+ * forever and every full-screen TUI (Claude's own UI, vim, htop) rendered into the wrong box.
+ *
+ * The stock fallback is dead by construction: `TerminalView.updateSize()` (verified in
+ * `terminal-view-v0.118.0.aar`, offsets 18–25) early-returns when `mTermSession == null`, which is
+ * permanently our case. [TerminalResizeDriver] is the replacement: it turns a real layout size + the
+ * renderer's real cell metrics into a grid via the already-tested pure [TerminalGridMath], and pushes it
+ * at the session (which routes it through the confined writer — see [RemoteTerminalSessionResizeTest]).
+ */
+class TerminalResizeDriverTest {
+
+ /** Metrics in the shape `TerminalRenderer` reports them: a fractional advance width, an int line box. */
+ private val metrics = TerminalCellMetrics(fontWidthPx = 10f, lineSpacingPx = 20)
+
+ /** One push at the driver's outlet: the grid, and whether it was flagged as a forced re-assert. */
+ private data class Push(val grid: TerminalGridSize, val isReassert: Boolean)
+
+ private class Recorder {
+ val pushes = mutableListOf()
+ val grids: List get() = pushes.map { it.grid }
+ fun apply(grid: TerminalGridSize, isReassert: Boolean) {
+ pushes += Push(grid, isReassert)
+ }
+ }
+
+ private fun driver(recorder: Recorder, metrics: () -> TerminalCellMetrics? = { this.metrics }) =
+ TerminalResizeDriver(cellMetrics = metrics, applyGrid = recorder::apply)
+
+ @Test
+ fun `a laid-out size resolves to the expected grid`() {
+ val recorder = Recorder()
+ driver(recorder).onViewSize(widthPx = 800, heightPx = 600, horizontalPaddingPx = 0, verticalPaddingPx = 0)
+
+ assertEquals(listOf(TerminalGridSize(cols = 80, rows = 30)), recorder.grids)
+ }
+
+ @Test
+ fun `padding is subtracted on both edges`() {
+ val recorder = Recorder()
+ driver(recorder).onViewSize(800, 600, horizontalPaddingPx = 15, verticalPaddingPx = 10)
+
+ // (800 - 30)/10 = 77 cols · (600 - 20)/20 = 29 rows
+ assertEquals(listOf(TerminalGridSize(cols = 77, rows = 29)), recorder.grids)
+ }
+
+ @Test
+ fun `a repeated identical size is skipped so rotation does not spam the socket`() {
+ val recorder = Recorder()
+ val driver = driver(recorder)
+
+ driver.onViewSize(800, 600, 0, 0)
+ driver.onViewSize(800, 600, 0, 0)
+ driver.onViewSize(800, 600, 0, 0)
+
+ assertEquals(1, recorder.grids.size, "an unchanged grid must not be pushed again")
+ }
+
+ @Test
+ fun `a different pixel size that resolves to the same grid is also skipped`() {
+ val recorder = Recorder()
+ val driver = driver(recorder)
+
+ driver.onViewSize(800, 600, 0, 0) // 80x30
+ driver.onViewSize(805, 605, 0, 0) // still 80x30 — sub-cell growth, no new grid
+
+ assertEquals(1, recorder.grids.size, "dedup is on the GRID, not on the pixel size")
+ }
+
+ @Test
+ fun `a genuinely new grid is pushed`() {
+ val recorder = Recorder()
+ val driver = driver(recorder)
+
+ driver.onViewSize(800, 600, 0, 0)
+ driver.onViewSize(600, 800, 0, 0) // rotation
+
+ assertEquals(
+ listOf(TerminalGridSize(80, 30), TerminalGridSize(60, 40)),
+ recorder.grids,
+ )
+ }
+
+ @Test
+ fun `a pre-layout size is dropped`() {
+ val recorder = Recorder()
+ val driver = driver(recorder)
+
+ driver.onViewSize(0, 0, 0, 0)
+ driver.onViewSize(800, 0, 0, 0)
+ driver.onViewSize(0, 600, 0, 0)
+
+ assertTrue(recorder.grids.isEmpty(), "a 0-dimension view has no valid grid to send")
+ }
+
+ @Test
+ fun `no metrics yet means no resize`() {
+ // The renderer only exists after setTextSize; before that there is nothing to divide by.
+ val recorder = Recorder()
+ driver(recorder, metrics = { null }).onViewSize(800, 600, 0, 0)
+
+ assertTrue(recorder.grids.isEmpty(), "a missing renderer must not produce a bogus grid")
+ }
+
+ @Test
+ fun `degenerate metrics are dropped rather than producing a divide-by-zero grid`() {
+ val recorder = Recorder()
+ driver(recorder, metrics = { TerminalCellMetrics(0f, 0) }).onViewSize(800, 600, 0, 0)
+
+ assertTrue(recorder.grids.isEmpty())
+ }
+
+ @Test
+ fun `a normal layout push is not a re-assert`() {
+ val recorder = Recorder()
+ driver(recorder).onViewSize(800, 600, 0, 0)
+
+ assertEquals(listOf(Push(TerminalGridSize(80, 30), isReassert = false)), recorder.pushes)
+ }
+
+ @Test
+ fun `re-asserting re-pushes the measured grid and flags it as forced`() {
+ // §6.4 latest-writer-wins: on device switch / pane-show the device that comes back must re-assert
+ // its dims to win the shared PTY size back. The re-push must be FLAGGED, because the writer
+ // downstream dedups on its own `lastSentDims` — an unflagged identical grid dies there and the
+ // reclaim never reaches the wire (the exact double-dedup a review caught).
+ val recorder = Recorder()
+ val driver = driver(recorder)
+
+ driver.onViewSize(800, 600, 0, 0)
+ driver.reassertLastGrid()
+
+ assertEquals(
+ listOf(
+ Push(TerminalGridSize(80, 30), isReassert = false),
+ Push(TerminalGridSize(80, 30), isReassert = true),
+ ),
+ recorder.pushes,
+ )
+ }
+
+ @Test
+ fun `re-asserting does not re-measure, so it works before the next layout pass`() {
+ // The reclaim fires from a lifecycle/focus callback, NOT from onSizeChanged: there is no new
+ // layout pass to piggyback on, so the driver must push from its own memo.
+ val recorder = Recorder()
+ val driver = driver(recorder)
+
+ driver.onViewSize(800, 600, 0, 0)
+ driver.reassertLastGrid()
+ driver.reassertLastGrid()
+
+ assertEquals(3, recorder.pushes.size)
+ assertTrue(recorder.pushes.drop(1).all { it.isReassert })
+ }
+
+ @Test
+ fun `re-asserting before anything was measured pushes nothing`() {
+ val recorder = Recorder()
+
+ driver(recorder).reassertLastGrid()
+
+ assertTrue(recorder.pushes.isEmpty(), "there is no grid to re-assert yet; the first layout will push")
+ }
+
+ @Test
+ fun `a re-assert leaves the memo intact so the next identical layout is still deduped`() {
+ val recorder = Recorder()
+ val driver = driver(recorder)
+
+ driver.onViewSize(800, 600, 0, 0)
+ driver.reassertLastGrid()
+ driver.onViewSize(800, 600, 0, 0)
+
+ assertEquals(2, recorder.pushes.size, "layout churn must not ride along on the reclaim")
+ }
+}
diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGestureTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGestureTest.kt
new file mode 100644
index 0000000..24699bf
--- /dev/null
+++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGestureTest.kt
@@ -0,0 +1,186 @@
+package wang.yaojia.webterm.terminalview
+
+import com.termux.terminal.TerminalEmulator
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_APPLICATION_CURSOR_KEYS
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_MOUSE_TRACKING
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER
+import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed
+
+/**
+ * The scroll arithmetic and branch selection, on their own — no view, no `MotionEvent`.
+ *
+ * The emulator here is the REAL Termux one driven by REAL escape sequences, so "alternate buffer" and
+ * "application cursor keys" are the same state the crashing stock branch tests, not a boolean we made up.
+ */
+class TerminalScrollGestureTest {
+
+ private class FakeTarget(private val emulator: TerminalEmulator?) : TerminalScrollTarget {
+ val keys = mutableListOf()
+ val wheelReports = mutableListOf()
+ var transcriptSteps = 0
+
+ override fun emulator(): TerminalEmulator? = emulator
+ override fun scrollTranscript(up: Boolean) {
+ transcriptSteps += if (up) -1 else 1
+ }
+
+ override fun sendKeys(data: String) {
+ keys += data
+ }
+
+ override fun reportMouseWheel(up: Boolean) {
+ wheelReports += up
+ }
+ }
+
+ private fun gesture(target: FakeTarget, cellHeightPx: Int = CELL_HEIGHT_PX) =
+ TerminalScrollGesture(TOUCH_SLOP_PX, { cellHeightPx }, target)
+
+ private fun emulatorWith(vararg sequences: String): TerminalEmulator =
+ StockTerminalViewFixture.emulator().also { emulator -> sequences.forEach { emulator.feed(it) } }
+
+ @Test
+ fun `the drag is not claimed until it passes the touch slop`() {
+ val target = FakeTarget(emulatorWith())
+ val scroll = gesture(target)
+
+ assertFalse(scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1)))
+ assertFalse(
+ scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - (TOUCH_SLOP_PX - 1), pointerCount = 1)),
+ "a sub-slop wobble is a tap, and taps must stay with the stock view",
+ )
+ assertTrue(scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - TOUCH_SLOP_PX * 4, pointerCount = 1)))
+ assertTrue(scroll.isOwned)
+ }
+
+ @Test
+ fun `the slop travel is spent on the claim and does not scroll a row`() {
+ val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
+ val scroll = gesture(target)
+
+ scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1))
+ // A claim that jumps a whole cell would make every drag start with a phantom keystroke.
+ scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - 5 * CELL_HEIGHT_PX, pointerCount = 1))
+
+ assertTrue(target.keys.isEmpty())
+ }
+
+ @Test
+ fun `an alternate-screen buffer turns rows into DECCKM-correct arrows`() {
+ val csi = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
+ gesture(csi).apply { dragUp(cells = 2) }
+
+ val ss3 = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER, ENABLE_APPLICATION_CURSOR_KEYS))
+ gesture(ss3).apply { dragUp(cells = 2) }
+
+ assertEquals(listOf("\u001b[B", "\u001b[B"), csi.keys)
+ assertEquals(listOf("\u001bOB", "\u001bOB"), ss3.keys, "application-cursor mode must switch form")
+ }
+
+ @Test
+ fun `mouse tracking outranks the alternate buffer, exactly like stock doScroll`() {
+ val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER, ENABLE_MOUSE_TRACKING))
+ gesture(target).apply { dragUp(cells = 2) }
+
+ assertEquals(listOf(false, false), target.wheelReports, "two wheel-down reports, no cursor keys")
+ assertTrue(target.keys.isEmpty())
+ }
+
+ @Test
+ fun `the normal buffer scrolls the transcript and puts nothing on the wire`() {
+ val target = FakeTarget(emulatorWith())
+ gesture(target).apply { dragUp(cells = 3) }
+
+ assertEquals(3, target.transcriptSteps, "dragging up walks toward the newest row")
+ assertTrue(target.keys.isEmpty())
+ assertTrue(target.wheelReports.isEmpty())
+ }
+
+ @Test
+ fun `sub-cell travel accumulates instead of being lost`() {
+ val target = FakeTarget(emulatorWith())
+ val scroll = gesture(target)
+ scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1))
+ scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - CLAIM_TRAVEL_PX, pointerCount = 1))
+
+ // Three moves of a third of a cell each == exactly one row, not zero.
+ var y = 500f - CLAIM_TRAVEL_PX
+ repeat(3) {
+ y -= CELL_HEIGHT_PX / 3f
+ scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = y, pointerCount = 1))
+ }
+
+ assertEquals(1, target.transcriptSteps)
+ }
+
+ @Test
+ fun `a second finger leaves the gesture to the stock pinch detector`() {
+ val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
+ val scroll = gesture(target)
+
+ scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1))
+ assertFalse(scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 100f, pointerCount = 2)))
+ assertTrue(target.keys.isEmpty())
+ }
+
+ @Test
+ fun `a wheel notch scrolls three rows, the stock amount`() {
+ val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
+
+ gesture(target).onMouseWheel(scrollUp = true)
+
+ assertEquals(List(TerminalScrollGesture.MOUSE_WHEEL_ROWS) { "\u001b[A" }, target.keys)
+ }
+
+ @Test
+ fun `an unmeasured cell keeps the gesture but scrolls nothing`() {
+ val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
+ val scroll = gesture(target, cellHeightPx = 0)
+
+ scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1))
+ scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - CLAIM_TRAVEL_PX, pointerCount = 1))
+
+ // Handing the drag back mid-gesture would put the stock scroll path in play again.
+ assertTrue(scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 0f, pointerCount = 1)))
+ assertTrue(target.keys.isEmpty())
+ }
+
+ @Test
+ fun `nothing is emitted while no emulator is bound`() {
+ val target = FakeTarget(null)
+ gesture(target).apply { dragUp(cells = 4) }
+
+ assertTrue(target.keys.isEmpty())
+ assertEquals(0, target.transcriptSteps)
+ }
+
+ @Test
+ fun `the gesture ends and the next drag has to re-earn its claim`() {
+ val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
+ val scroll = gesture(target)
+ scroll.dragUp(cells = 1)
+
+ assertFalse(scroll.isOwned, "an ended gesture must not leak ownership into the next tap")
+ assertFalse(scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1)))
+ }
+
+ /** Claim the drag, then travel [cells] whole cells upward, then lift. */
+ private fun TerminalScrollGesture.dragUp(cells: Int) {
+ onTouch(TouchFacts(TouchPhase.DOWN, y = START_Y, pointerCount = 1))
+ onTouch(TouchFacts(TouchPhase.MOVE, y = START_Y - CLAIM_TRAVEL_PX, pointerCount = 1))
+ val end = START_Y - CLAIM_TRAVEL_PX - cells * CELL_HEIGHT_PX
+ onTouch(TouchFacts(TouchPhase.MOVE, y = end, pointerCount = 1))
+ onTouch(TouchFacts(TouchPhase.END, y = end, pointerCount = 1))
+ }
+
+ private companion object {
+ const val TOUCH_SLOP_PX: Int = 8
+ const val CELL_HEIGHT_PX: Int = 24
+ const val START_Y: Float = 800f
+ const val CLAIM_TRAVEL_PX: Float = 100f
+ }
+}
diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt
new file mode 100644
index 0000000..29050fc
--- /dev/null
+++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt
@@ -0,0 +1,173 @@
+package wang.yaojia.webterm.terminalview
+
+import android.view.KeyEvent
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * The client end-to-end: a key press arrives the way Termux delivers it — with a **null**
+ * `TerminalSession`, exactly as `TerminalView.onKeyDown` passes its own null `mTermSession` at offset
+ * 88–92 — and must come out as bytes on the sink, with `true` returned so the stock method stops at
+ * `invalidate(); return true` (offset 97–105) instead of walking on into the session it does not have.
+ *
+ * `KeyEvent` here is the AGP mockable stub (`isReturnDefaultValues = true`), so every accessor reports
+ * "unmodified, non-system, no keymap character" — which is precisely the shape of a bare arrow press,
+ * the case that matters for DECCKM.
+ */
+class WebTermTerminalViewClientTest {
+
+ private class FakeSink(
+ private val appHandles: Boolean = false,
+ private var modes: TerminalCursorModes? = TerminalCursorModes(false, false),
+ ) : TerminalInputSink {
+ val sent = mutableListOf()
+ var taps = 0
+ var sizes = mutableListOf>()
+ var keysOfferedToApp = 0
+
+ fun setCursorApplicationMode(on: Boolean) {
+ modes = TerminalCursorModes(cursorKeysApplication = on, keypadApplication = false)
+ }
+
+ override fun appConsumesKey(keyCode: Int, event: KeyEvent): Boolean {
+ keysOfferedToApp++
+ return appHandles
+ }
+
+ override fun cursorModes(): TerminalCursorModes? = modes
+ override fun sendInput(data: String) {
+ sent += data
+ }
+
+ override fun onTapped() {
+ taps++
+ }
+
+ override fun onViewSize(widthPx: Int, heightPx: Int) {
+ sizes += widthPx to heightPx
+ }
+ }
+
+ private fun downEvent() = KeyEvent(KeyEvent.ACTION_DOWN, 0)
+ private fun upEvent() = KeyEvent(KeyEvent.ACTION_UP, 0)
+
+ @Test
+ fun `a key press with a null session emits bytes and is consumed`() {
+ val sink = FakeSink()
+ val client = WebTermTerminalViewClient(sink)
+
+ // `session = null` is what the stock view actually passes — the very argument that made the old
+ // "return false and let Termux write it" shape impossible.
+ val consumed = client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null)
+
+ assertTrue(consumed, "returning false would let TerminalView fall through toward mTermSession")
+ assertEquals(listOf("\u001b[A"), sink.sent)
+ }
+
+ @Test
+ fun `the emulator's live DECCKM mode selects SS3 over CSI`() {
+ val sink = FakeSink()
+ val client = WebTermTerminalViewClient(sink)
+
+ client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null)
+ sink.setCursorApplicationMode(true)
+ client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null)
+
+ assertEquals(listOf("\u001b[A", "\u001bOA"), sink.sent, "vim/htop need SS3 once DECCKM is set")
+ }
+
+ @Test
+ fun `Enter sends CR`() {
+ val sink = FakeSink()
+ WebTermTerminalViewClient(sink).onKeyDown(KeyEvent.KEYCODE_ENTER, downEvent(), null)
+
+ assertEquals(listOf("\r"), sink.sent)
+ }
+
+ @Test
+ fun `an app-handled chord is offered to the outlet and not double-sent`() {
+ val sink = FakeSink(appHandles = true)
+ val client = WebTermTerminalViewClient(sink)
+
+ val consumed = client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null)
+
+ assertTrue(consumed)
+ assertEquals(1, sink.keysOfferedToApp, ":app's HardwareKeyRouter must get first refusal")
+ assertTrue(sink.sent.isEmpty(), ":app already sent the bytes; sending again would double-type")
+ }
+
+ @Test
+ fun `key-up is consumed and sends nothing`() {
+ val sink = FakeSink()
+ val consumed = WebTermTerminalViewClient(sink).onKeyUp(KeyEvent.KEYCODE_DPAD_UP, upEvent())
+
+ assertTrue(consumed)
+ assertTrue(sink.sent.isEmpty())
+ }
+
+ @Test
+ fun `onCodePoint never falls through to the session`() {
+ val sink = FakeSink()
+ val handled = WebTermTerminalViewClient(sink).onCodePoint('c'.code, ctrlDown = true, session = null)
+
+ assertTrue(handled, "false here would let inputCodePoint reach mTermSession.writeCodePoint")
+ assertEquals(listOf("\u0003"), sink.sent)
+ }
+
+ @Test
+ fun `the back button is never mapped to escape`() {
+ // True would send a system BACK press down TerminalView.onKeyDown's terminal branch (offset
+ // 113-136), whose next stop is mTermSession.write(...). This flag is load-bearing, not cosmetic.
+ assertFalse(WebTermTerminalViewClient(FakeSink()).shouldBackButtonBeMappedToEscape())
+ }
+
+ @Test
+ fun `a long press is consumed so the stock text-selection mode never starts`() {
+ // TerminalView$1.onLongPress offsets 14-30: `if (mClient.onLongPress(e)) return;`. Returning false
+ // lets it call startTextSelectionMode, whose ActionMode is a crash, not a feature — "Copy"
+ // (TextSelectionCursorController$1.onActionItemClicked offsets 89-100) is
+ // `mTermSession.onCopyTextToClipboard(...)` and "Paste" (offsets 126-136) is
+ // `mTermSession.onPasteTextFromClipboard()`, both unguarded against the session we never have.
+ assertTrue(
+ WebTermTerminalViewClient(FakeSink()).onLongPress(null),
+ "false here arms an ActionMode whose Copy button NPEs on mTermSession",
+ )
+ }
+
+ @Test
+ fun `a tap raises the keyboard and pinch-zoom is declined`() {
+ val sink = FakeSink()
+ val client = WebTermTerminalViewClient(sink)
+
+ client.onSingleTapUp(null)
+
+ assertEquals(1, sink.taps)
+ assertEquals(1.0f, client.onScale(3.0f), "a changed font size would silently break the grid maths")
+ }
+
+ @Test
+ fun `logging callbacks are inert so keystrokes never reach logcat`() {
+ val client = WebTermTerminalViewClient(FakeSink())
+ // Termux funnels key codes and typed text through these; they must be no-ops (plan §8).
+ client.logInfo("tag", "onKeyDown(keyCode=31, event=…)")
+ client.logError("tag", "secret")
+ client.logWarn("tag", "secret")
+ client.logDebug("tag", "secret")
+ client.logVerbose("tag", "secret")
+ client.logStackTrace("tag", Exception("secret"))
+ client.logStackTraceWithMessage("tag", "secret", Exception("secret"))
+ }
+
+ @Test
+ fun `no modifier latching leaks into the key tables`() {
+ val client = WebTermTerminalViewClient(FakeSink())
+ // Termux's extra-keys bar latches these; ours sends finished byte sequences instead, so a latched
+ // "true" here would silently add Ctrl/Alt to every subsequent keystroke.
+ assertFalse(client.readControlKey())
+ assertFalse(client.readAltKey())
+ assertFalse(client.readShiftKey())
+ assertFalse(client.readFnKey())
+ }
+}
diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt
new file mode 100644
index 0000000..2ea293c
--- /dev/null
+++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt
@@ -0,0 +1,173 @@
+package wang.yaojia.webterm.transport
+
+import okhttp3.Cookie
+import okhttp3.CookieJar
+import okhttp3.HttpUrl
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * The client's cookie jar for the optional `WEBTERM_TOKEN` gate (server `src/http/auth.ts`).
+ *
+ * When a host is token-gated, `GET /?token=…` (or `POST /auth`) answers with
+ * `Set-Cookie: webterm_auth=…; HttpOnly; SameSite=Strict[; Secure]`, and from then on EVERY remote
+ * HTTP route **and the WS handshake** require that cookie. The upgrade is a plain HTTP request, so
+ * installing this jar on the one shared [okhttp3.OkHttpClient] covers both paths at once — OkHttp's
+ * `BridgeInterceptor` runs for WebSocket calls too.
+ *
+ * ── Isolation (security-load-bearing) ────────────────────────────────────────────────────────────
+ * The cookie is a **shell credential**. Two independent layers keep it on its own host:
+ * 1. storage is partitioned by [authCookieHostKey] (`scheme://host:port`), so another host's key
+ * simply has no entry to read;
+ * 2. what survives that lookup is still filtered through OkHttp's own `Cookie.matches(url)`, which
+ * applies the RFC domain/path rules and refuses to put a `Secure` cookie on cleartext.
+ *
+ * Expiry is enforced on BOTH sides of the boundary: expired entries are pruned when read (and the
+ * pruning is pushed to storage) and dropped when restored, so a stale credential is never
+ * resurrected after a long background or a clock jump.
+ *
+ * Everything is in-memory and synchronous — OkHttp calls a `CookieJar` on the call thread.
+ * Durability is the [AuthCookiePersister]'s job (`:host-registry` DataStore, adapted in `:app`);
+ * [restore] rehydrates at cold start. A cookie jar is NOT a cache: the shared client keeps
+ * `.cache(null)` (plan §8) untouched.
+ *
+ * @param persister where a host's changed cookie set is handed for durable storage.
+ * @param clock wall-clock source (injected for tests); must be the same epoch as `expiresAt`.
+ */
+public class AuthCookieJar(
+ private val persister: AuthCookiePersister = AuthCookiePersister.NONE,
+ private val clock: () -> Long = System::currentTimeMillis,
+) : CookieJar {
+
+ /** hostKey → that host's cookies. Values are immutable lists, replaced wholesale, never mutated. */
+ private val byHostKey = ConcurrentHashMap>()
+
+ /** Serialises the read-modify-write of a host's list (reads stay lock-free through the map). */
+ private val writeLock = Any()
+
+ override fun loadForRequest(url: HttpUrl): List {
+ val hostKey = authCookieHostKey(url)
+ val stored = byHostKey[hostKey] ?: return emptyList()
+ val live = pruneExpired(hostKey, stored)
+ // Second layer: RFC domain/path match + the Secure-over-cleartext refusal.
+ return live.filter { it.matches(url) }
+ }
+
+ override fun saveFromResponse(url: HttpUrl, cookies: List) {
+ if (cookies.isEmpty()) return
+ val hostKey = authCookieHostKey(url)
+ val now = clock()
+ synchronized(writeLock) {
+ store(hostKey, byHostKey[hostKey].orEmpty().merged(cookies, now))
+ }
+ }
+
+ /**
+ * Rehydrate one host's cookies at cold start (from `:host-registry`). Already-expired snapshots
+ * and structurally invalid ones are dropped. This is a hydration path, not a change, so the
+ * [AuthCookiePersister] is deliberately NOT notified.
+ */
+ public fun restore(hostKey: String, cookies: List) {
+ val now = clock()
+ val live = cookies
+ .filter { it.expiresAtEpochMillis > now }
+ .mapNotNull { it.toCookieOrNull() }
+ .capped()
+ synchronized(writeLock) {
+ if (live.isEmpty()) byHostKey.remove(hostKey) else byHostKey[hostKey] = live
+ }
+ }
+
+ /**
+ * Forget one host's cookies (unpair / "sign out of this host") and tell storage to do the same.
+ * Other hosts are untouched.
+ */
+ public fun clear(hostKey: String) {
+ synchronized(writeLock) { byHostKey.remove(hostKey) }
+ persister.persist(hostKey, emptyList())
+ }
+
+ /** Redacted by construction — a cookie jar's contents are credentials. */
+ override fun toString(): String = "AuthCookieJar(hosts=${byHostKey.size})"
+
+ // ── internals ───────────────────────────────────────────────────────────────────────────────
+
+ /** Drops expired entries, publishing the pruned set (so storage forgets them too) iff it changed. */
+ private fun pruneExpired(hostKey: String, stored: List): List {
+ val now = clock()
+ val live = stored.filter { it.expiresAt > now }
+ if (live.size == stored.size) return stored
+ synchronized(writeLock) {
+ // Re-read under the lock: a concurrent save may have replaced the list meanwhile.
+ val current = byHostKey[hostKey].orEmpty()
+ store(hostKey, current.filter { it.expiresAt > now })
+ }
+ return live
+ }
+
+ /** The single write path: cap, publish in memory, then hand the persistent subset to storage. */
+ private fun store(hostKey: String, cookies: List) {
+ val capped = cookies.capped()
+ if (capped.isEmpty()) byHostKey.remove(hostKey) else byHostKey[hostKey] = capped
+ // Session cookies (no Max-Age/Expires) must not outlive the process — memory only.
+ persister.persist(hostKey, capped.filter { it.persistent }.map { it.toSnapshot() })
+ }
+}
+
+/** Keep the most recent [MAX_COOKIES_PER_HOST] entries (oldest evicted first). */
+private fun List.capped(): List =
+ if (size <= MAX_COOKIES_PER_HOST) this else takeLast(MAX_COOKIES_PER_HOST)
+
+/**
+ * Fold [incoming] into the stored set: same-identity cookies are replaced IN PLACE (position
+ * preserved), an already-expired incoming cookie deletes its stored twin (the `Max-Age=0` logout
+ * shape), and anything stored that has expired is dropped on the way through. Returns a NEW list.
+ */
+private fun List.merged(incoming: List, now: Long): List =
+ incoming.fold(filter { it.expiresAt > now }) { acc, cookie ->
+ if (cookie.expiresAt > now) acc.upserting(cookie) else acc.filterNot { it.sameIdentity(cookie) }
+ }
+
+private fun List.upserting(cookie: Cookie): List =
+ if (any { it.sameIdentity(cookie) }) {
+ map { if (it.sameIdentity(cookie)) cookie else it }
+ } else {
+ this + cookie
+ }
+
+/** RFC 6265 cookie identity: (name, domain, path). The host partition is applied one level up. */
+private fun Cookie.sameIdentity(other: Cookie): Boolean =
+ name == other.name && domain == other.domain && path == other.path
+
+private fun Cookie.toSnapshot(): AuthCookieSnapshot =
+ AuthCookieSnapshot(
+ name = name,
+ value = value,
+ domain = domain,
+ path = path,
+ expiresAtEpochMillis = expiresAt,
+ secure = secure,
+ httpOnly = httpOnly,
+ hostOnly = hostOnly,
+ )
+
+/**
+ * Rebuild an OkHttp [Cookie] from storage. Values at rest are untrusted (a tampered or
+ * format-drifted blob) — an unparsable record yields null and is dropped rather than crashing the
+ * jar. `expiresAt` is set explicitly, so a restored cookie is always persistent.
+ */
+private fun AuthCookieSnapshot.toCookieOrNull(): Cookie? =
+ try {
+ Cookie.Builder()
+ .name(name)
+ .value(value)
+ .expiresAt(expiresAtEpochMillis)
+ .path(path)
+ .let { if (hostOnly) it.hostOnlyDomain(domain) else it.domain(domain) }
+ .let { if (secure) it.secure() else it }
+ .let { if (httpOnly) it.httpOnly() else it }
+ .build()
+ } catch (_: IllegalArgumentException) {
+ null // never log the exception payload — it can carry the cookie value
+ } catch (_: IllegalStateException) {
+ null
+ }
diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt
new file mode 100644
index 0000000..350e779
--- /dev/null
+++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt
@@ -0,0 +1,82 @@
+package wang.yaojia.webterm.transport
+
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+
+/**
+ * The name of the server's shared-access-token cookie (`src/http/auth.ts` `AUTH_COOKIE_NAME`).
+ * Exposed as a constant so nothing has to hard-code the string; the jar itself is name-agnostic
+ * (it stores whatever the paired host sets, scoped to that host).
+ */
+public const val AUTH_COOKIE_NAME: String = "webterm_auth"
+
+/**
+ * Upper bound on how many cookies are retained per host. The paired server sets exactly one
+ * ([AUTH_COOKIE_NAME]); the cap is a boundary guard so a broken or hostile host cannot turn the
+ * client into an unbounded credential sink. Oldest entries are evicted first.
+ */
+internal const val MAX_COOKIES_PER_HOST: Int = 16
+
+/**
+ * A storable, OkHttp-free view of one cookie — the wire between [AuthCookieJar] and whatever
+ * persists it (`:host-registry`'s `AuthCookieStore`, adapted in `:app`).
+ *
+ * The DTO is declared HERE rather than shared with `:host-registry` on purpose: `:transport-okhttp`
+ * must keep depending only on `:wire-protocol` (+ OkHttp), exactly like the [ClientIdentityProvider]
+ * seam avoids a `:client-tls` edge. `:app` maps the two field-for-field.
+ *
+ * [expiresAtEpochMillis] is ABSOLUTE (wall-clock ms), never a `Max-Age` duration: persisting the raw
+ * `Set-Cookie` header would silently restart the lifetime on every restore and extend a shell
+ * credential past what the server granted.
+ *
+ * [toString] is REDACTED. [value] is the credential — it must never reach a log, a crash report or a
+ * `toString()`.
+ */
+public data class AuthCookieSnapshot(
+ val name: String,
+ val value: String,
+ val domain: String,
+ val path: String,
+ val expiresAtEpochMillis: Long,
+ val secure: Boolean = false,
+ val httpOnly: Boolean = false,
+ val hostOnly: Boolean = true,
+) {
+ override fun toString(): String =
+ "AuthCookieSnapshot(name=$name, value=, domain=$domain, path=$path, " +
+ "expiresAtEpochMillis=$expiresAtEpochMillis, secure=$secure, httpOnly=$httpOnly, hostOnly=$hostOnly)"
+}
+
+/**
+ * Where a host's cookie set goes when it changes. Deliberately NON-suspending: OkHttp calls the
+ * `CookieJar` synchronously on the call thread, so the implementation must hand off (e.g.
+ * `scope.launch { store.replaceHost(...) }`) rather than block on DataStore I/O.
+ *
+ * The callback always carries the host's COMPLETE persistent cookie set, so the implementation is a
+ * wholesale replace — an empty list means "this host has no persisted cookie any more" (server-side
+ * deletion, expiry, or unpair), never "nothing changed".
+ */
+public fun interface AuthCookiePersister {
+ /** @param hostKey the isolation key from [authCookieHostKey] — never a bare hostname. */
+ public fun persist(hostKey: String, cookies: List)
+
+ public companion object {
+ /** Memory-only: the cookie lives for the process lifetime and is never written down. */
+ public val NONE: AuthCookiePersister = AuthCookiePersister { _, _ -> }
+ }
+}
+
+/**
+ * The per-host isolation key: `scheme://host:port` with the port ALWAYS explicit
+ * (`HttpUrl.port` fills in the scheme default). Stricter than the cookie RFC on purpose — browsers
+ * scope cookies by domain alone, which would hand a host's shell credential to a different service
+ * on the same machine or to the same name over a different scheme.
+ *
+ * The WS upgrade lands on the same key as REST: OkHttp normalises `ws(s)://` to `http(s)://`, and a
+ * `HostEndpoint`'s `wsUrl` and `baseUrl` share scheme/host/port by construction.
+ */
+public fun authCookieHostKey(url: HttpUrl): String = "${url.scheme}://${url.host}:${url.port}"
+
+/** Same key derived from a `HostEndpoint.baseUrl` (or any absolute http(s) URL); null if unparsable. */
+public fun authCookieHostKey(baseUrl: String): String? =
+ baseUrl.toHttpUrlOrNull()?.let { authCookieHostKey(it) }
diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/CleartextGuard.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/CleartextGuard.kt
new file mode 100644
index 0000000..897c3ef
--- /dev/null
+++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/CleartextGuard.kt
@@ -0,0 +1,91 @@
+package wang.yaojia.webterm.transport
+
+import okhttp3.HttpUrl
+import okhttp3.Interceptor
+import okhttp3.Response
+import wang.yaojia.webterm.wire.HostClassifier
+import wang.yaojia.webterm.wire.HostNetworkTier
+import java.net.UnknownServiceException
+
+/**
+ * The app refused to send this request **in plaintext** because the target host is not on a network
+ * where cleartext is allowed (plan §6.9 / §8 "Cleartext posture").
+ *
+ * The supertype is load-bearing, twice over:
+ * - it is a `java.io.IOException`, so OkHttp reports it through the normal failure path
+ * (`Callback.onFailure` / `WebSocketListener.onFailure`) and every existing caller already handles
+ * it — nothing leaves the device before it is thrown;
+ * - it is specifically [UnknownServiceException], which is EXACTLY what the Android platform throws
+ * when `network_security_config` blocks cleartext — and what `:api-client`'s
+ * `PairingError.classify` already maps to `PairingError.CleartextBlocked`. So a refused dial
+ * surfaces as "this host must use https/wss", not as the misleading "host unreachable"
+ * (`ConnectException`) — the user can tell "the app refused this" from "the host is down" with no
+ * changes anywhere else. Do not widen this supertype without re-checking that classifier.
+ *
+ * @param host the URL host that was refused (never a credential — safe to show and to log).
+ * @param tier what [HostClassifier] made of that host (always a non-cleartext tier here).
+ */
+public class CleartextNotPermittedException(
+ public val host: String,
+ public val tier: HostNetworkTier,
+) : UnknownServiceException(
+ "Refused to send an unencrypted request to \"$host\" ($tier): plaintext http/ws is permitted " +
+ "only for loopback, private-LAN and Tailscale hosts. Use https/wss for this host.",
+)
+
+/**
+ * The transport-time half of the cleartext allowlist.
+ *
+ * Android's `network_security_config` is the platform half, and it genuinely **cannot** express a
+ * CIDR/prefix — the format takes exact domains only. So the shipped config permits cleartext in
+ * `` (plus a TLS-only `` for the tunnel apex), and the CIDR scoping plan
+ * §6.9 asks for is enforced here, where a real classification is possible:
+ *
+ * | tier | cleartext (`http`/`ws`) | why |
+ * |---|---|---|
+ * | [HostNetworkTier.LOOPBACK] | permitted | on-device only; the hook side-channel and local dev |
+ * | [HostNetworkTier.PRIVATE_LAN] | permitted | THE bare-LAN posture (RFC1918 · 169.254/16 · `.local`) |
+ * | [HostNetworkTier.TAILSCALE] | permitted | CGNAT 100.64/10 · `*.ts.net` — already WireGuard-encrypted |
+ * | [HostNetworkTier.PUBLIC] | **refused** | includes every unclassifiable host — fail closed |
+ *
+ * `https`/`wss` is never touched: TLS to a public host is the tunnel/Tailscale path and stays on
+ * default system trust.
+ */
+internal object CleartextPolicy {
+
+ /** The tiers whose plaintext dial is allowed. Everything else — including "unknown" — is not. */
+ private val CLEARTEXT_TIERS: Set = setOf(
+ HostNetworkTier.LOOPBACK,
+ HostNetworkTier.PRIVATE_LAN,
+ HostNetworkTier.TAILSCALE,
+ )
+
+ /**
+ * The refusal for [url], or null when the dial is allowed. Pure: OkHttp has already normalised
+ * `ws(s)://` to `http(s)://` by the time a request exists, so [HttpUrl.isHttps] is the whole
+ * encrypted/not test.
+ */
+ fun refusalFor(url: HttpUrl): CleartextNotPermittedException? {
+ if (url.isHttps) return null
+ val tier = HostClassifier.classify(url.host)
+ if (tier in CLEARTEXT_TIERS) return null
+ return CleartextNotPermittedException(host = url.host, tier = tier)
+ }
+}
+
+/**
+ * Installs [CleartextPolicy] on the one shared client (see [OkHttpClientFactory]). It runs as an
+ * **application** interceptor — first in the chain, before DNS, before the connection pool, before
+ * the cookie jar attaches a credential — so a refused request never touches the network.
+ *
+ * That placement is safe only because the client also pins `followRedirects(false)` /
+ * `followSslRedirects(false)`: an application interceptor does not see redirect follow-ups, so a
+ * `3xx` to `http://` would otherwise slip past this check. The two must stay together.
+ */
+internal class CleartextGuardInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ CleartextPolicy.refusalFor(request.url)?.let { throw it }
+ return chain.proceed(request)
+ }
+}
diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
index ca64aae..937e739 100644
--- a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
+++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
@@ -1,5 +1,6 @@
package wang.yaojia.webterm.transport
+import okhttp3.CookieJar
import okhttp3.OkHttpClient
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
@@ -48,23 +49,45 @@ public fun interface ClientIdentityProvider {
* one client → one `SSLSocketFactory` → mTLS applies uniformly to WS and REST, and the connection
* pool is shared.
*
- * Two invariants baked in here:
+ * Five invariants baked in here:
* - **`.cache(null)`** (plan §8): no ephemeral/disk HTTP cache — preview/diff bodies can hold
- * terminal secrets, so nothing is persisted.
+ * terminal secrets, so nothing is persisted. A [cookieJar] is NOT a cache and never re-enables it.
* - **Server-cert trust = default system trust.** Tunnel (`*.terminal.yaojia.wang`) and Tailscale
* MagicDNS present real LE certs, so NO custom `X509TrustManager` is installed for the common
- * case (plan §6.9). Bare-LAN `ws://` cleartext is an `:app` `network_security_config` allowlist
- * concern, NOT this module's — a plain `ws://` upgrade needs no TLS here at all.
+ * case (plan §6.9).
+ * - **Cleartext is scoped to LAN/Tailscale/loopback** ([CleartextGuardInterceptor]). The `:app`
+ * `network_security_config` is only half the control: the format has no CIDR/prefix syntax, so it
+ * can only permit cleartext globally. The CIDR half of plan §6.9 is enforced here — a plaintext
+ * `http`/`ws` dial to a PUBLIC host fails fast with [CleartextNotPermittedException] instead of
+ * silently succeeding.
+ * - **No redirects followed** (`followRedirects` / `followSslRedirects` = false). No route in plan
+ * §4.2/§4.3 legitimately redirects, and following one would let a hostile or compromised host
+ * `3xx` an `https` call down to `http` — past the cleartext guard, which (as an application
+ * interceptor) does not see redirect follow-ups. Callers get the `3xx` verbatim.
+ * - **One cookie jar for WS + REST.** The optional `WEBTERM_TOKEN` gate answers pairing with a
+ * `webterm_auth` cookie that every later HTTP route AND the WS upgrade require. Because both
+ * transports share this client, installing an [AuthCookieJar] here puts the cookie on the upgrade
+ * request too (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Default is
+ * [CookieJar.NO_COOKIES] — a host with no token gate behaves exactly as before.
*/
public object OkHttpClientFactory {
/**
* @param identityProvider optional mTLS material; [ClientIdentityProvider.NONE] → default trust,
* no client cert.
+ * @param cookieJar session-cookie store; [CookieJar.NO_COOKIES] disables cookies entirely.
*/
public fun create(
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
+ cookieJar: CookieJar = CookieJar.NO_COOKIES,
): OkHttpClient {
- val builder = OkHttpClient.Builder().cache(null)
+ val builder = OkHttpClient.Builder()
+ .cache(null)
+ .cookieJar(cookieJar)
+ // First in the chain: refuse a public-host plaintext dial before DNS, the connection pool
+ // or the cookie jar can act on it.
+ .addInterceptor(CleartextGuardInterceptor())
+ .followRedirects(false)
+ .followSslRedirects(false)
identityProvider.currentIdentity()?.let { identity ->
builder.sslSocketFactory(identity.sslSocketFactory, identity.trustManager)
}
@@ -86,8 +109,9 @@ public class OkHttpTransports private constructor(
public companion object {
public fun create(
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
+ cookieJar: CookieJar = CookieJar.NO_COOKIES,
): OkHttpTransports {
- val client = OkHttpClientFactory.create(identityProvider)
+ val client = OkHttpClientFactory.create(identityProvider, cookieJar)
return OkHttpTransports(
term = OkHttpTermTransport(client),
http = OkHttpHttpTransport(client),
diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt
new file mode 100644
index 0000000..f1dea54
--- /dev/null
+++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt
@@ -0,0 +1,351 @@
+package wang.yaojia.webterm.transport
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import wang.yaojia.webterm.wire.HostEndpoint
+import wang.yaojia.webterm.wire.HttpMethod
+import wang.yaojia.webterm.wire.HttpRequest
+
+/**
+ * The `WEBTERM_TOKEN` session-cookie path (server: `src/http/auth.ts`). The gate sets an
+ * `HttpOnly; SameSite=Strict; Secure-when-https` cookie named `webterm_auth` at pairing and then
+ * requires it on EVERY remote HTTP route **and on the WS upgrade** (which is a plain HTTP request —
+ * without the cookie the socket 401s before it ever opens).
+ *
+ * These tests pin four things:
+ * 1. **replay** — a cookie set by a host rides every later request to that host, REST and WS alike;
+ * 2. **host isolation** — host A's cookie is NEVER offered to host B (a shell credential; a
+ * cross-host leak is a security bug, not a cosmetic one);
+ * 3. **scope/expiry** — expiry and the `Secure` scope are honoured (OkHttp's own `Cookie.matches`),
+ * and a server deletion (`Max-Age=0`) is propagated to storage;
+ * 4. **no credential in any log/toString path**.
+ *
+ * The `Origin` byte-equality assertion is repeated on the WS upgrade here on purpose: adding the
+ * `Cookie` header must not disturb THE CSWSH defence.
+ */
+class AuthCookieJarTest {
+
+ private lateinit var server: MockWebServer
+ private lateinit var otherServer: MockWebServer
+ private val persister = RecordingPersister()
+ private var nowOffsetMs: Long = 0L
+ private val jar = AuthCookieJar(persister = persister, clock = { System.currentTimeMillis() + nowOffsetMs })
+ private lateinit var client: OkHttpClient
+
+ @BeforeEach
+ fun setUp() {
+ server = MockWebServer().apply { start() }
+ otherServer = MockWebServer().apply { start() }
+ client = OkHttpClientFactory.create(cookieJar = jar)
+ }
+
+ @AfterEach
+ fun tearDown() {
+ client.dispatcher.cancelAll()
+ client.connectionPool.evictAll()
+ runCatching { server.shutdown() }
+ runCatching { otherServer.shutdown() }
+ client.dispatcher.executorService.shutdown()
+ }
+
+ private fun transport() = OkHttpHttpTransport(client)
+
+ private fun MockWebServer.get(path: String) = runBlocking {
+ transport().send(HttpRequest(HttpMethod.GET, url(path).toString()))
+ }
+
+ private fun setCookieResponse(attributes: String = "Path=/; Max-Age=$MAX_AGE_SEC; HttpOnly; SameSite=Strict") =
+ MockResponse()
+ .setResponseCode(200)
+ .addHeader("Set-Cookie", "$AUTH_COOKIE_NAME=$TOKEN; $attributes")
+ .setBody("{}")
+
+ // ── 1. replay ────────────────────────────────────────────────────────────────
+
+ @Test
+ fun replaysTheAuthCookieOnEveryLaterRequestToTheSameHost() {
+ // Arrange: pairing response carries the Set-Cookie; two later reads follow.
+ server.enqueue(setCookieResponse())
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act
+ server.get("/?token=$TOKEN")
+ server.get("/live-sessions")
+ server.get("/config/ui")
+
+ // Assert: the pairing request itself had no cookie; both later ones did.
+ assertNull(server.takeRequest().getHeader("Cookie"))
+ assertEquals("$AUTH_COOKIE_NAME=$TOKEN", server.takeRequest().getHeader("Cookie"))
+ assertEquals("$AUTH_COOKIE_NAME=$TOKEN", server.takeRequest().getHeader("Cookie"))
+ }
+
+ @Test
+ fun carriesTheCookieOnTheWsUpgradeWithTheOriginStillByteEqual() = runBlocking {
+ // Arrange: pair over REST (cookie captured), then dial the WS on the SAME host.
+ server.enqueue(setCookieResponse())
+ val serverWs = OpenedServerWebSocket()
+ server.enqueue(MockResponse().withWebSocketUpgrade(serverWs))
+ server.get("/?token=$TOKEN")
+ server.takeRequest() // drop the pairing request
+ val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}"))
+
+ // Act
+ val connection = withTimeout(TIMEOUT_MS) { OkHttpTermTransport(client).connect(endpoint) }
+
+ // Assert: the UPGRADE request carries the cookie AND the byte-equal Origin (CSWSH defence).
+ val upgrade = server.takeRequest()
+ assertEquals("$AUTH_COOKIE_NAME=$TOKEN", upgrade.getHeader("Cookie"))
+ assertEquals(endpoint.originHeader, upgrade.getHeader("Origin"))
+ connection.close()
+ }
+
+ @Test
+ fun restoredCookiesAreReplayedAfterAColdStart() {
+ // Arrange: a fresh process — nothing in memory, everything rehydrated from storage.
+ val coldJar = AuthCookieJar(clock = { System.currentTimeMillis() + nowOffsetMs })
+ val coldClient = OkHttpClientFactory.create(cookieJar = coldJar)
+ val hostKey = requireNotNull(authCookieHostKey(server.url("/").toString()))
+ coldJar.restore(hostKey, listOf(snapshot(expiresAtEpochMillis = System.currentTimeMillis() + HOUR_MS)))
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act
+ runBlocking {
+ OkHttpHttpTransport(coldClient)
+ .send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString()))
+ }
+
+ // Assert
+ assertEquals("$AUTH_COOKIE_NAME=$TOKEN", server.takeRequest().getHeader("Cookie"))
+ coldClient.connectionPool.evictAll()
+ coldClient.dispatcher.executorService.shutdown()
+ }
+
+ // ── 2. host isolation ────────────────────────────────────────────────────────
+
+ @Test
+ fun neverSendsAHostsCookieToADifferentHost() {
+ // Arrange: host A hands out the credential; host B must never see it.
+ server.enqueue(setCookieResponse())
+ otherServer.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act
+ server.get("/?token=$TOKEN")
+ otherServer.get("/live-sessions")
+
+ // Assert
+ val leaked = otherServer.takeRequest().getHeader("Cookie")
+ assertNull(leaked, "host A's shell credential must never ride a request to host B")
+ }
+
+ @Test
+ fun clearForgetsOneHostsCookiesAndLeavesTheOthersAlone() {
+ // Arrange: both hosts are authed.
+ server.enqueue(setCookieResponse())
+ otherServer.enqueue(setCookieResponse())
+ server.get("/?token=$TOKEN")
+ otherServer.get("/?token=$TOKEN")
+ server.takeRequest()
+ otherServer.takeRequest()
+
+ // Act: unpair host A only.
+ jar.clear(requireNotNull(authCookieHostKey(server.url("/").toString())))
+
+ // Assert
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+ otherServer.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+ server.get("/live-sessions")
+ otherServer.get("/live-sessions")
+ assertNull(server.takeRequest().getHeader("Cookie"))
+ assertEquals("$AUTH_COOKIE_NAME=$TOKEN", otherServer.takeRequest().getHeader("Cookie"))
+ }
+
+ // ── 3. scope + expiry ────────────────────────────────────────────────────────
+
+ @Test
+ fun stopsReplayingAnExpiredCookieAndPersistsTheRemoval() {
+ // Arrange
+ server.enqueue(setCookieResponse())
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+ server.get("/?token=$TOKEN")
+ server.takeRequest()
+
+ // Act: jump past Max-Age.
+ nowOffsetMs = (MAX_AGE_SEC + 1) * 1_000L
+ server.get("/live-sessions")
+
+ // Assert: not sent any more, and storage was told to drop it.
+ assertNull(server.takeRequest().getHeader("Cookie"))
+ assertEquals(emptyList(), persister.latestFor(hostKey()))
+ }
+
+ @Test
+ fun honoursAServerSideDeletionOfTheCookie() {
+ // Arrange: authed, then the server expires the cookie (Max-Age=0 — the logout shape).
+ server.enqueue(setCookieResponse())
+ server.enqueue(setCookieResponse(attributes = "Path=/; Max-Age=0"))
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act
+ server.get("/?token=$TOKEN")
+ server.get("/live-sessions")
+ server.get("/live-sessions")
+
+ // Assert
+ server.takeRequest()
+ server.takeRequest()
+ assertNull(server.takeRequest().getHeader("Cookie"))
+ assertEquals(emptyList(), persister.latestFor(hostKey()))
+ }
+
+ @Test
+ fun doesNotRestoreACookieThatAlreadyExpiredAtRest() {
+ // Arrange: storage handed back a stale record (clock skew / long-backgrounded app).
+ val coldJar = AuthCookieJar(clock = { System.currentTimeMillis() + nowOffsetMs })
+ val coldClient = OkHttpClientFactory.create(cookieJar = coldJar)
+ coldJar.restore(hostKey(), listOf(snapshot(expiresAtEpochMillis = System.currentTimeMillis() - 1)))
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act
+ runBlocking {
+ OkHttpHttpTransport(coldClient)
+ .send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString()))
+ }
+
+ // Assert
+ assertNull(server.takeRequest().getHeader("Cookie"))
+ coldClient.connectionPool.evictAll()
+ coldClient.dispatcher.executorService.shutdown()
+ }
+
+ @Test
+ fun aSecureCookieIsNeverSentOverCleartext() {
+ // Arrange: a Secure cookie restored for a cleartext LAN host (the bare-LAN ws:// posture).
+ jar.restore(
+ hostKey(),
+ listOf(snapshot(expiresAtEpochMillis = System.currentTimeMillis() + HOUR_MS, secure = true)),
+ )
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act
+ server.get("/live-sessions")
+
+ // Assert
+ assertNull(server.takeRequest().getHeader("Cookie"))
+ }
+
+ @Test
+ fun persistsOnlyTheHostThatIssuedTheCookieAndOnlyPersistentCookies() {
+ // Arrange: one persistent (Max-Age) + one session cookie (no Max-Age/Expires).
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .addHeader("Set-Cookie", "$AUTH_COOKIE_NAME=$TOKEN; Path=/; Max-Age=$MAX_AGE_SEC")
+ .addHeader("Set-Cookie", "transient=x; Path=/")
+ .setBody("{}"),
+ )
+
+ // Act
+ server.get("/?token=$TOKEN")
+
+ // Assert: storage is told about the persistent one only (a session cookie must not outlive
+ // the process), and it is filed under this host's key.
+ val persisted = persister.latestFor(hostKey())
+ assertEquals(listOf(AUTH_COOKIE_NAME), persisted?.map { it.name })
+ assertEquals(TOKEN, persisted?.single()?.value)
+ }
+
+ @Test
+ fun capsTheNumberOfCookiesStoredPerHost() {
+ // Arrange: a hostile/broken server tries to make the client an unbounded cookie sink.
+ val response = MockResponse().setResponseCode(200).setBody("{}")
+ repeat(MAX_COOKIES_PER_HOST * 2) { i -> response.addHeader("Set-Cookie", "c$i=v$i; Path=/; Max-Age=$MAX_AGE_SEC") }
+ server.enqueue(response)
+
+ // Act
+ server.get("/")
+
+ // Assert
+ assertEquals(MAX_COOKIES_PER_HOST, persister.latestFor(hostKey())?.size)
+ }
+
+ // ── 4. the credential never reaches a log/toString path ──────────────────────
+
+ @Test
+ fun neverExposesTheCookieValueInAnyToStringPath() {
+ // Arrange
+ server.enqueue(setCookieResponse())
+ server.get("/?token=$TOKEN")
+
+ // Act
+ val rendered = listOf(
+ jar.toString(),
+ persister.latestFor(hostKey()).toString(),
+ persister.latestFor(hostKey())!!.single().toString(),
+ AuthCookieJar().toString(),
+ )
+
+ // Assert: the token/cookie value appears nowhere; the name/host key may.
+ rendered.forEach { text ->
+ assertFalse(text.contains(TOKEN), "credential leaked into a log/toString path: $text")
+ }
+ assertTrue(rendered[2].contains(AUTH_COOKIE_NAME), "the redacted rendering should still identify the cookie")
+ }
+
+ private fun hostKey(): String = requireNotNull(authCookieHostKey(server.url("/").toString()))
+
+ private fun snapshot(
+ expiresAtEpochMillis: Long,
+ secure: Boolean = false,
+ ) = AuthCookieSnapshot(
+ name = AUTH_COOKIE_NAME,
+ value = TOKEN,
+ domain = server.hostName,
+ path = "/",
+ expiresAtEpochMillis = expiresAtEpochMillis,
+ secure = secure,
+ httpOnly = true,
+ hostOnly = true,
+ )
+
+ private companion object {
+ const val TOKEN = "s3cr3t-webterm-token-value"
+ const val MAX_AGE_SEC = 60L
+ const val HOUR_MS = 3_600_000L
+ const val TIMEOUT_MS = 5_000L
+ }
+}
+
+/** Records the last persisted snapshot per host key — the `:app`/DataStore side of the seam. */
+private class RecordingPersister : AuthCookiePersister {
+ private val latest = mutableMapOf>()
+
+ override fun persist(hostKey: String, cookies: List) {
+ latest[hostKey] = cookies
+ }
+
+ fun latestFor(hostKey: String): List? = latest[hostKey]
+}
+
+/** Minimal server-side WS listener: only "the upgrade happened" matters here. */
+private class OpenedServerWebSocket : WebSocketListener() {
+ val opened = CompletableDeferred()
+
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ opened.complete(webSocket)
+ }
+}
diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/CleartextGuardTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/CleartextGuardTest.kt
new file mode 100644
index 0000000..5524903
--- /dev/null
+++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/CleartextGuardTest.kt
@@ -0,0 +1,304 @@
+package wang.yaojia.webterm.transport
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.runBlocking
+import okhttp3.Dns
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertInstanceOf
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import wang.yaojia.webterm.wire.HostEndpoint
+import wang.yaojia.webterm.wire.HostNetworkTier
+import wang.yaojia.webterm.wire.HttpMethod
+import wang.yaojia.webterm.wire.HttpRequest
+import java.io.IOException
+import java.net.InetAddress
+import java.net.UnknownServiceException
+
+/**
+ * Transport-time cleartext scoping (plan §6.9 / §8 "Cleartext posture").
+ *
+ * Android's `network_security_config` has no CIDR/prefix syntax, so the platform file can only permit
+ * cleartext globally (plus a TLS-only `` for the tunnel apex). The CIDR allowlist the
+ * platform cannot express is enforced HERE, on the one shared client: a plaintext (`http`/`ws`) dial
+ * is allowed only for loopback / private-LAN / Tailscale hosts
+ * ([wang.yaojia.webterm.wire.HostClassifier]); a plaintext dial to a PUBLIC host fails fast with the
+ * typed [CleartextNotPermittedException] before a byte leaves the device.
+ *
+ * Two layers of test, on purpose:
+ * - **wiring** — real [MockWebServer] exchanges through the real client. A non-loopback URL *host*
+ * reaches the loopback server via a pinned [Dns], so the guard sees the host it would see in the
+ * field and "refused" is proven by `requestCount == 0`. (Only hostnames can be pinned this way:
+ * OkHttp resolves an IP literal itself and never consults `Dns`.)
+ * - **the decision table** — [CleartextPolicy] over every CIDR edge, including the ones no local
+ * socket can stand in for (10/8, 172.16/12, CGNAT …).
+ */
+class CleartextGuardTest {
+
+ private lateinit var server: MockWebServer
+
+ @BeforeEach
+ fun setUp() {
+ server = MockWebServer().apply { start() }
+ }
+
+ @AfterEach
+ fun tearDown() {
+ runCatching { server.shutdown() }
+ }
+
+ // ── wiring: permitted tiers reach the host ──────────────────────────────────────────────────
+
+ @Test
+ fun permitsCleartextToAPrivateLanHost() {
+ // Arrange: the bare-LAN posture — http:// to an mDNS LAN name (PRIVATE_LAN).
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act
+ val response = get("http://mybox.local:${server.port}/live-sessions")
+
+ // Assert: the dial went through to the host.
+ assertEquals(200, response.status)
+ assertEquals(1, server.requestCount)
+ }
+
+ @Test
+ fun permitsCleartextToATailscaleMagicDnsHost() {
+ // Arrange: *.ts.net is already WireGuard-encrypted — cleartext there must keep working.
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act
+ val response = get("http://mybox.tailnet-1a2b.ts.net:${server.port}/live-sessions")
+
+ // Assert
+ assertEquals(200, response.status)
+ assertEquals(1, server.requestCount)
+ }
+
+ @Test
+ fun permitsCleartextToLoopbackSoLocalFlowsKeepWorking() {
+ // Arrange
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act: the plain MockWebServer URL (localhost/127.0.0.1) — no DNS pinning, no guard in the way.
+ val response = runBlocking {
+ OkHttpHttpTransport(OkHttpClientFactory.create())
+ .send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString()))
+ }
+
+ // Assert
+ assertEquals(200, response.status)
+ assertEquals(1, server.requestCount)
+ }
+
+ // ── wiring: cleartext to a public host is refused ───────────────────────────────────────────
+
+ @Test
+ fun refusesCleartextToAPublicHostBeforeAnyByteLeavesTheDevice() {
+ // Arrange: a public name that would otherwise resolve straight to the live server.
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act: caught as IOException on purpose — the refusal MUST be one, or OkHttp's async path
+ // cannot surface it as a call failure.
+ val failure = assertThrows {
+ get("http://terminal.example.com:${server.port}/live-sessions")
+ }
+
+ // Assert: typed, self-describing, and nothing was sent.
+ val refusal = assertInstanceOf(CleartextNotPermittedException::class.java, failure)
+ assertEquals("terminal.example.com", refusal.host)
+ assertEquals(HostNetworkTier.PUBLIC, refusal.tier)
+ assertTrue(
+ refusal.message.orEmpty().contains("terminal.example.com"),
+ "the refusal must name the host it refused: ${refusal.message}",
+ )
+ assertEquals(0, server.requestCount, "a refused cleartext dial must never reach the network")
+ }
+
+ @Test
+ fun theRefusalIsTheSameTypeThePlatformUsesSoTheExistingPairingCopyApplies() {
+ // `:api-client`'s PairingError.classify maps java.net.UnknownServiceException — the platform's
+ // own network_security_config cleartext block — to PairingError.CleartextBlocked ("this host
+ // must use https/wss"). Our refusal must land in that bucket, NOT in HostUnreachable, or the
+ // user cannot tell "the app refused this" from "the host is down".
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ val failure = assertThrows {
+ get("http://terminal.example.com:${server.port}/live-sessions")
+ }
+
+ assertInstanceOf(CleartextNotPermittedException::class.java, failure)
+ }
+
+ @Test
+ fun refusesACleartextWebSocketUpgradeToAPublicHost() {
+ // Arrange: the terminal stream itself — the plaintext path that matters most.
+ val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://terminal.example.com:${server.port}"))
+ server.enqueue(MockResponse().withWebSocketUpgrade(NeverReachedServerWebSocket()))
+
+ // Act
+ val failure = assertThrows {
+ runBlocking { OkHttpTermTransport(pinnedToLoopback()).connect(endpoint) }
+ }
+
+ // Assert
+ assertEquals("terminal.example.com", failure.host)
+ assertEquals(0, server.requestCount, "the ws:// upgrade must be refused before the socket opens")
+ }
+
+ @Test
+ fun refusesCleartextToAnUnclassifiableHostFailSafe() {
+ // Arrange: an out-of-range dotted quad is not an IP at all — HostClassifier fails safe to
+ // PUBLIC, so must the guard.
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act + Assert
+ assertThrows {
+ get("http://999.1.2.3:${server.port}/live-sessions")
+ }
+ assertEquals(0, server.requestCount)
+ }
+
+ // ── wiring: https is untouched ──────────────────────────────────────────────────────────────
+
+ @Test
+ fun leavesHttpsToAPublicHostAlone() {
+ // Arrange: the tunnel/Tailscale shape. The MockWebServer speaks cleartext, so the handshake
+ // will fail — what matters is HOW it fails: at the socket, not at our guard.
+ server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
+
+ // Act
+ val failure = assertThrows {
+ get("https://terminal.example.com:${server.port}/live-sessions")
+ }
+
+ // Assert: the guard did NOT veto it — the call got as far as TLS.
+ assertFalse(
+ failure is CleartextNotPermittedException,
+ "https must never be refused by the cleartext guard, got: $failure",
+ )
+ }
+
+ // ── wiring: no silent scheme downgrade ──────────────────────────────────────────────────────
+
+ @Test
+ fun doesNotFollowRedirectsSoAHostCannotDowngradeTheClient() {
+ // Arrange: a host answers with a 3xx pointing at a public http:// URL.
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(302)
+ .addHeader("Location", "http://terminal.example.com:${server.port}/live-sessions"),
+ )
+
+ // Act
+ val response = get("http://mybox.local:${server.port}/live-sessions")
+
+ // Assert: the 3xx is handed to the caller verbatim; the client chased nothing.
+ assertEquals(302, response.status)
+ assertEquals(1, server.requestCount)
+ }
+
+ @Test
+ fun theSharedClientRefusesToFollowRedirectsAtAll() {
+ val client = OkHttpClientFactory.create()
+ assertFalse(client.followRedirects, "no plan §4.2/§4.3 route legitimately redirects")
+ assertFalse(client.followSslRedirects, "an https→http downgrade must never be chased")
+ }
+
+ // ── the decision table (every CIDR edge, incl. ones no local socket can stand in for) ────────
+
+ @Test
+ fun permitsCleartextForEveryLoopbackPrivateAndTailscaleHost() {
+ val allowed = listOf(
+ "127.0.0.1", "127.1.2.3", "localhost", // loopback
+ "10.0.0.5", "10.255.255.255", // RFC1918 10/8
+ "172.16.0.1", "172.31.255.254", // RFC1918 172.16/12
+ "192.168.1.50", // RFC1918 192.168/16
+ "169.254.7.7", // link-local
+ "mybox.local", "MyBox.Local", // mDNS (case-insensitive)
+ "100.64.0.1", "100.127.255.254", // CGNAT / Tailscale
+ "mybox.tailnet-1a2b.ts.net", // MagicDNS
+ )
+
+ allowed.forEach { host ->
+ assertNull(
+ CleartextPolicy.refusalFor("http://$host:3000/live-sessions".toHttpUrl()),
+ "cleartext to $host is a supported path and must not be refused",
+ )
+ }
+ }
+
+ @Test
+ fun refusesCleartextForPublicAndOutOfRangeHosts() {
+ val refused = mapOf(
+ "8.8.8.8" to HostNetworkTier.PUBLIC, // plainly public
+ "172.15.0.1" to HostNetworkTier.PUBLIC, // just below RFC1918 172.16/12
+ "172.32.0.1" to HostNetworkTier.PUBLIC, // just above it
+ "100.63.255.255" to HostNetworkTier.PUBLIC, // just below CGNAT
+ "100.128.0.1" to HostNetworkTier.PUBLIC, // just above it
+ "192.169.1.1" to HostNetworkTier.PUBLIC, // near-miss on 192.168/16
+ "169.253.7.7" to HostNetworkTier.PUBLIC, // near-miss on link-local
+ "terminal.yaojia.wang" to HostNetworkTier.PUBLIC, // the tunnel — TLS-only
+ "ts.net.attacker.example" to HostNetworkTier.PUBLIC, // suffix spoof
+ "999.1.2.3" to HostNetworkTier.PUBLIC, // unclassifiable → fail closed
+ )
+
+ refused.forEach { (host, tier) ->
+ val refusal = CleartextPolicy.refusalFor("http://$host:3000/live-sessions".toHttpUrl())
+ assertEquals(host, refusal?.host, "cleartext to $host must be refused")
+ assertEquals(tier, refusal?.tier)
+ }
+ }
+
+ @Test
+ fun neverRefusesAnEncryptedDialWhateverTheTier() {
+ listOf("terminal.yaojia.wang", "8.8.8.8", "192.168.1.50", "mybox.tailnet-1a2b.ts.net").forEach { host ->
+ assertNull(
+ CleartextPolicy.refusalFor("https://$host/live-sessions".toHttpUrl()),
+ "https is out of the guard's scope — $host must pass through",
+ )
+ }
+ }
+
+ // ── helpers ─────────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * A client whose DNS always answers loopback, so a non-loopback URL host reaches [server].
+ * Hostnames only — OkHttp parses an IP literal itself and never calls [Dns].
+ */
+ private fun pinnedToLoopback(): OkHttpClient =
+ OkHttpClientFactory.create()
+ .newBuilder()
+ .dns(LoopbackDns)
+ .build()
+
+ private fun get(url: String) = runBlocking {
+ OkHttpHttpTransport(pinnedToLoopback()).send(HttpRequest(HttpMethod.GET, url))
+ }
+}
+
+/** Resolves every hostname to loopback (the MockWebServer's bind address). */
+private object LoopbackDns : Dns {
+ override fun lookup(hostname: String): List = listOf(InetAddress.getLoopbackAddress())
+}
+
+/** Minimal server-side WS listener; [opened] must stay incomplete — the upgrade is refused. */
+private class NeverReachedServerWebSocket : WebSocketListener() {
+ val opened = CompletableDeferred()
+
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ opened.complete(webSocket)
+ }
+}
diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt
index a9ba58c..8b34958 100644
--- a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt
+++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt
@@ -1,5 +1,6 @@
package wang.yaojia.webterm.transport
+import okhttp3.CookieJar
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
@@ -36,7 +37,11 @@ class OkHttpClientFactoryTest {
.socketFactory
// Act
- val client = OkHttpClientFactory.create { ClientIdentity(sslSocketFactory, trustManager) }
+ // (named argument: `create` now also takes a trailing `cookieJar`, so a trailing lambda
+ // would bind to the wrong parameter.)
+ val client = OkHttpClientFactory.create(
+ identityProvider = { ClientIdentity(sslSocketFactory, trustManager) },
+ )
// Assert: the shared client uses exactly the injected factory (mTLS applies to WS + REST).
assertSame(sslSocketFactory, client.sslSocketFactory)
@@ -50,6 +55,27 @@ class OkHttpClientFactoryTest {
assertNull(transports.client.cache)
}
+ @Test
+ fun defaultClientCarriesNoCookieJar() {
+ // The gate is opt-in: an unconfigured client behaves exactly as before (LAN zero-config).
+ assertSame(CookieJar.NO_COOKIES, OkHttpClientFactory.create().cookieJar)
+ }
+
+ @Test
+ fun injectedCookieJarIsWiredWithoutEnablingTheHttpCache() {
+ // Arrange
+ val jar = AuthCookieJar()
+
+ // Act
+ val client = OkHttpClientFactory.create(cookieJar = jar)
+
+ // Assert: the jar reaches BOTH transports through the one shared client, and a cookie jar
+ // is NOT a cache — `.cache(null)` (plan §8) stays intact.
+ assertSame(jar, client.cookieJar)
+ assertNull(client.cache, "a cookie jar must never re-enable the HTTP cache")
+ assertSame(jar, OkHttpTransports.create(cookieJar = jar).client.cookieJar)
+ }
+
private fun systemDefaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)