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?)