From 3e5cfdc1cf3bd9b342d256addc27f7d1e117ca3b Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 08:59:01 +0200 Subject: [PATCH] fix(android): make the terminal actually usable on a device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client built to an APK and 617 JVM tests passed, but nothing had ever run on hardware, and three defects made it unusable the moment it did. All three were invisible to the JVM suite because none of those tests instantiate an Android View. 1. Every key press crashed. `RemoteTerminalView` bound the forked emulator but never installed a `TerminalViewClient`, and the stock Termux view dereferences `mClient` with no null guard on the first line of the paths a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection` offset 0, `onKeyUp`, `onKeyPreIme`). Installing any client is not enough: returning false continues into `mTermSession.write()` at offset 150, and `mTermSession` is null forever because `TerminalSession` is final and forking a process is what this app must not do. So the client consumes the key itself and `KeyRouting` has no defer-to-stock branch at all — the crash is unrepresentable, not merely avoided. Termux's `KeyHandler` stays the authority for the key tables, so DECCKM still emits `ESC O A`. 2. The PTY never learned the real size. Nothing called `RemoteTerminalSession.updateSize`, and the stock fallback is dead (`TerminalView.updateSize` early-returns without a session), so every full-screen TUI rendered into 80x24. `TerminalResizeDriver` now drives it from real cell metrics — read through the public `getFontWidth()` / `getFontLineSpacing()` accessors rather than reflection, which would break silently under R8, or a re-measured Paint, which would drift from what the renderer actually draws (plan risk R5). 3. Bare-LAN connections were impossible. `network_security_config.xml` was a self-labelled STUB denying all cleartext while `HostEndpoint` accepts http and derives ws://. The format has no CIDR syntax, so the allowlist its own comment promised cannot be written; cleartext is permitted in base-config, the tunnel domain keeps a TLS block, trust anchors are pinned to system-only, and the guard moves to the §5.4 pairing tiers. Adversarial review then found three more, and one it got wrong: - Swipe-scrolling inside an alternate-screen TUI still crashed. The touch path bypasses `TerminalViewClient` entirely: `doScroll` turns a scroll into `handleKeyCode` whenever the alternate buffer is active, and that dereferences `mTermSession`. Claude Code is an alternate-screen TUI, so this was a crash during normal use on the app's main screen. `TerminalScrollGesture` claims the vertical drag first and reproduces all three `doScroll` branches, closing the fling runnable and `onGenericMotionEvent` with it. - `autofill()` dereferences `mTermSession` unguarded while the view advertises itself autofillable, so any password manager crashed it. The subtree is excluded from the autofill structure. - The review asked for stock text selection to be restored. It cannot be: the ActionMode's own Copy and Paste handlers dereference the absent session, so a reachable selection UI has an NPE on its Copy button. Long press stays consumed and copy-out is recorded as a feature gap. Also: the WEBTERM_TOKEN cookie is carried on REST and the WS upgrade and sealed with Tink AEAD under an AndroidKeyStore key (fail-closed — a seal failure persists nothing rather than falling back to plaintext), and `allowBackup=false` keeps a 30-day shell credential off Google Drive. Verified: ./gradlew test :app:assembleDebug -> 757 JVM tests, 0 failures. Nothing here is device-verified yet; that is the next step. --- android/app/src/main/AndroidManifest.xml | 21 +- .../wang/yaojia/webterm/push/FcmService.kt | 17 +- .../yaojia/webterm/screens/PairingScreen.kt | 3 + .../webterm/viewmodels/PairingViewModel.kt | 6 +- .../main/res/xml/network_security_config.xml | 88 ++++- .../webterm/NetworkSecurityConfigTest.kt | 112 ++++++ .../tlsandroid/TinkAuthCookieCipherTest.kt | 129 +++++++ .../tlsandroid/EnrollmentRecordStore.kt | 17 +- .../yaojia/webterm/tlsandroid/TinkAead.kt | 43 +++ .../tlsandroid/TinkAuthCookieCipher.kt | 97 +++++ .../webterm/tlsandroid/TinkCertStore.kt | 24 +- .../DataStoreAuthCookieStoreTest.kt | 217 +++++++++++ .../webterm/hostregistry/AuthCookieCipher.kt | 47 +++ .../webterm/hostregistry/AuthCookieCodec.kt | 86 +++++ .../webterm/hostregistry/AuthCookieRecord.kt | 51 +++ .../webterm/hostregistry/AuthCookieStore.kt | 74 ++++ .../hostregistry/DataStoreAuthCookieStore.kt | 123 ++++++ .../hostregistry/InMemoryAuthCookieStore.kt | 48 +++ .../hostregistry/AuthCookieCodecTest.kt | 98 +++++ .../AuthCookieStoreTransformsTest.kt | 172 +++++++++ .../DataStoreAuthCookieStoreCryptoTest.kt | 292 +++++++++++++++ .../InMemoryAuthCookieStoreTest.kt | 124 +++++++ .../terminalview/RemoteTerminalHostView.kt | 347 +++++++++++++++++ .../RemoteTerminalInputConnection.kt | 104 ++++++ .../terminalview/RemoteTerminalSession.kt | 30 +- .../terminalview/RemoteTerminalView.kt | 124 +++++-- .../terminalview/TerminalInputBytes.kt | 153 ++++++++ .../webterm/terminalview/TerminalInputSink.kt | 37 ++ .../terminalview/TerminalKeyDecoder.kt | 131 +++++++ .../terminalview/TerminalResizeDriver.kt | 86 +++++ .../terminalview/TerminalScrollGesture.kt | 202 ++++++++++ .../terminalview/WebTermTerminalViewClient.kt | 190 ++++++++++ .../RemoteTerminalHostViewTest.kt | 207 +++++++++++ .../RemoteTerminalSessionResizeTest.kt | 162 ++++++++ .../terminalview/StockTerminalViewFixture.kt | 138 +++++++ .../StockTerminalViewHazardTest.kt | 74 ++++ .../terminalview/TerminalInputBytesTest.kt | 130 +++++++ .../terminalview/TerminalKeyDecoderTest.kt | 193 ++++++++++ .../terminalview/TerminalResizeDriverTest.kt | 183 +++++++++ .../terminalview/TerminalScrollGestureTest.kt | 186 ++++++++++ .../WebTermTerminalViewClientTest.kt | 173 +++++++++ .../yaojia/webterm/transport/AuthCookieJar.kt | 173 +++++++++ .../webterm/transport/AuthCookieSnapshot.kt | 82 ++++ .../webterm/transport/CleartextGuard.kt | 91 +++++ .../webterm/transport/OkHttpClientFactory.kt | 36 +- .../webterm/transport/AuthCookieJarTest.kt | 351 ++++++++++++++++++ .../webterm/transport/CleartextGuardTest.kt | 304 +++++++++++++++ .../transport/OkHttpClientFactoryTest.kt | 28 +- 48 files changed, 5719 insertions(+), 85 deletions(-) create mode 100644 android/app/src/test/java/wang/yaojia/webterm/NetworkSecurityConfigTest.kt create mode 100644 android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipherTest.kt create mode 100644 android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAead.kt create mode 100644 android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkAuthCookieCipher.kt create mode 100644 android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreTest.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCipher.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodec.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieRecord.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStore.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStore.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStore.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieCodecTest.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/AuthCookieStoreTransformsTest.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/DataStoreAuthCookieStoreCryptoTest.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAuthCookieStoreTest.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalInputConnection.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytes.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoder.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriver.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGesture.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostViewTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionResizeTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewHazardTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalInputBytesTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalKeyDecoderTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalResizeDriverTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalScrollGestureTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt create mode 100644 android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt create mode 100644 android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt create mode 100644 android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/CleartextGuard.kt create mode 100644 android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt create mode 100644 android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/CleartextGuardTest.kt 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?)