From 3e5cfdc1cf3bd9b342d256addc27f7d1e117ca3b Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 08:59:01 +0200 Subject: [PATCH 01/10] 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?) From 3fe719f8747eda52671ea32032b800b66c372f8e Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 09:39:50 +0200 Subject: [PATCH 02/10] feat(android): decode the two server frames the client was throwing away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both had shipped on the server for a while and Android silently discarded them, which is worse than not supporting them — the UI looked correct while missing information the user needed. W2 `queue`: `ServerMessageType` had no member for it, so `decodeServer` returned null and `SessionEngine.onFrame` counted it as undecodable and dropped it. The "N queued" badge could therefore never appear on Android, on any device, ever. Adding the branch made the compiler flag the non-exhaustive `when` in the engine, which is the same gap seen from the other side. A negative depth drops the frame rather than feeding nonsense to a badge; the next broadcast carries the real value. W1 `status.preview`: not modelled at all, so a remote approval was BLIND — the gate could show that `Bash` wanted to run, but not what it wanted to run, and the same for an Edit's diff. Approving blind defeats the point of the remote gate, which exists so whoever is holding the phone can make an informed call. `ApprovalPreview` now models both variants and `GateState.preview` carries it to the UI. Two decode rules are load-bearing and pinned by tests: - A broken preview must never cost the FRAME. `pending` is what makes the gate appear at all, so trading a missing preview for a missing gate would be a bad bargain. Every malformed shape — null, non-object, no kind, unknown kind, command without text, diff without a file — degrades the preview to null and still yields the Status. - Unknown values are kept, not coerced. A diff line kind or file status this client has never seen renders as itself; dropping a line we cannot classify would hide part of what is being approved. Only a diff file missing its paths is dropped, because then the preview cannot be trusted to describe the right file. A sustained pending refreshes the preview without minting a new epoch: the server's bounded render can arrive a frame after the gate rises, and a decision tapped in between has to stay valid. The preview's diff subset lives in :wire-protocol rather than reusing the :app diff model — :session-core may not depend on :app, and new wire types belong in the frozen contract module (CLAUDE.md). Verified: :wire-protocol:test :session-core:test + both koverVerify green; 16 new tests (10 codec, 6 engine-level). --- .../wang/yaojia/webterm/session/GateState.kt | 10 ++ .../yaojia/webterm/session/GateTracker.kt | 15 +- .../yaojia/webterm/session/SessionEngine.kt | 3 +- .../webterm/session/SessionEvent-cockpit.kt | 10 ++ .../SessionEngineQueueAndPreviewTest.kt | 168 ++++++++++++++++++ .../yaojia/webterm/wire/ApprovalPreview.kt | 81 +++++++++ .../wang/yaojia/webterm/wire/MessageCodec.kt | 71 +++++++- .../wang/yaojia/webterm/wire/ServerMessage.kt | 20 ++- .../webterm/wire/ApprovalPreviewDecodeTest.kt | 156 ++++++++++++++++ 9 files changed, 528 insertions(+), 6 deletions(-) create mode 100644 android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineQueueAndPreviewTest.kt create mode 100644 android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ApprovalPreview.kt create mode 100644 android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ApprovalPreviewDecodeTest.kt diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt index bab53df..5243d0b 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt @@ -1,5 +1,6 @@ package wang.yaojia.webterm.session +import wang.yaojia.webterm.wire.ApprovalPreview import wang.yaojia.webterm.wire.ApproveMode import wang.yaojia.webterm.wire.ClientMessage import wang.yaojia.webterm.wire.GateKind @@ -22,6 +23,15 @@ public data class GateState( val detail: String?, /** Rising-edge counter; see type doc. */ val epoch: Int, + /** + * W1 — the command or diff this gate is asking about, so the user is not approving blind. + * + * `null` is a NORMAL state, not an error: the server only sends a preview for a held Bash/Edit-family + * tool, and a malformed one decodes to null rather than costing us the gate. UI must therefore render + * a previewless gate as an ordinary gate, never as a failure. Every string inside is UNTRUSTED and + * must render inert (plan §8). + */ + val preview: ApprovalPreview? = null, ) { /** The action set for this gate: plan → three-way, tool → two-way. */ public val affordances: List diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt index 277ba59..6e6b78b 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt @@ -1,5 +1,7 @@ package wang.yaojia.webterm.session +import wang.yaojia.webterm.wire.ApprovalPreview + import wang.yaojia.webterm.wire.GateKind /** @@ -29,7 +31,12 @@ public class GateTracker private constructor( private val lastEpoch: Int, ) { /** Feeds one `status` frame's gate-relevant fields; returns the next snapshot. */ - public fun reduce(pending: Boolean, gate: GateKind?, detail: String?): GateTracker { + public fun reduce( + pending: Boolean, + gate: GateKind?, + detail: String?, + preview: ApprovalPreview? = null, + ): GateTracker { if (!pending) { // Falling edge (or still idle): gate lifted, epoch counter retained. return GateTracker(current = null, lastEpoch = lastEpoch) @@ -38,12 +45,14 @@ public class GateTracker private constructor( val held = current if (held != null) { // Sustained pending: same epoch, refresh kind/detail from the frame. - val refreshed = GateState(kind = kind, detail = detail, epoch = held.epoch) + // Refresh the preview too: the server may send the held status again with a preview it did + // not have on the rising edge (the bounded render can arrive a frame late). + val refreshed = GateState(kind = kind, detail = detail, epoch = held.epoch, preview = preview) return GateTracker(current = refreshed, lastEpoch = lastEpoch) } // Rising edge: mint the next epoch (the ONLY place it increments). val nextEpoch = lastEpoch + 1 - val risen = GateState(kind = kind, detail = detail, epoch = nextEpoch) + val risen = GateState(kind = kind, detail = detail, epoch = nextEpoch, preview = preview) return GateTracker(current = risen, lastEpoch = nextEpoch) } diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt index 4857a73..ee68997 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt @@ -281,6 +281,7 @@ public class SessionEngine( is ServerMessage.Output -> { emit(Output(msg.data)); null } is ServerMessage.Status -> { onStatus(msg); null } is ServerMessage.Telemetry -> { emit(Telemetry(msg.telemetry)); null } + is ServerMessage.Queue -> { emit(Queued(msg.length)); null } is ServerMessage.Exit -> { emit(Exited(msg.code, msg.reason)) EndCause.Terminal @@ -299,7 +300,7 @@ public class SessionEngine( private fun onStatus(status: ServerMessage.Status) { val previous = gateTracker.current - gateTracker = gateTracker.reduce(status.pending, status.gate, status.detail) + gateTracker = gateTracker.reduce(status.pending, status.gate, status.detail, status.preview) val next = gateTracker.current if (next != previous) emit(Gate(next)) } diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt index d2d465d..f228032 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt @@ -27,3 +27,13 @@ public data class Digest(val digest: AwayDigest) : SessionEvent /** Latest statusLine telemetry broadcast (mirrors the server `telemetry` frame, B2). */ public data class Telemetry(val telemetry: StatusTelemetry) : SessionEvent + +/** + * W2 — pending-inject queue depth for this session, broadcast to every attached device so all mirrors + * agree on the "N queued" badge. + * + * The server has always sent this frame; Android had no `ServerMessageType` member for it, so + * `decodeServer` returned null and `SessionEngine` discarded it as undecodable. [length] is + * non-negative — the codec drops a negative depth rather than passing nonsense to a badge. + */ +public data class Queued(val length: Int) : SessionEvent diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineQueueAndPreviewTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineQueueAndPreviewTest.kt new file mode 100644 index 0000000..a52b8a7 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineQueueAndPreviewTest.kt @@ -0,0 +1,168 @@ +package wang.yaojia.webterm.session + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeTermTransport +import wang.yaojia.webterm.wire.ApprovalPreview +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.TermTransport + +/** + * Engine-level proof for the two frames Android used to lose (W1 `status.preview`, W2 `queue`). + * + * `MessageCodec` decoding them is necessary but not sufficient: the reason they were invisible is that + * `SessionEngine.onFrame` is where a decoded frame becomes a `SessionEvent`, and a frame with no branch + * there is discarded just as silently as one that fails to decode. These tests drive the real engine + * through the fake transport so a future refactor that drops the branch fails here. + */ +@DisplayName("SessionEngine — queue depth and approval preview reach the UI") +class SessionEngineQueueAndPreviewTest { + + private fun endpoint(): HostEndpoint = HostEndpoint.fromBaseUrl("http://localhost:3000")!! + + private fun TestScope.newEngine(transport: TermTransport): SessionEngine = SessionEngine( + transport = transport, + endpoint = endpoint(), + scope = backgroundScope, + nowMs = { 0L }, + ) + + private fun TestScope.collectEvents(engine: SessionEngine): List { + val seen = mutableListOf() + backgroundScope.launch { engine.events.collect { seen += it } } + return seen + } + + private fun TestScope.started(transport: FakeTermTransport): Pair> { + val engine = newEngine(transport) + val events = collectEvents(engine) + engine.start() + testScheduler.runCurrent() + return engine to events + } + + @Test + @DisplayName("a queue frame becomes a Queued event instead of being dropped") + fun queueFrameSurfaces() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + transport.emit("""{"type":"queue","length":4}""") + testScheduler.runCurrent() + + assertEquals( + listOf(4), + events.filterIsInstance().map { it.length }, + "before this fix `queue` had no ServerMessageType member, so decodeServer returned null " + + "and onFrame discarded it as undecodable — the badge could never appear", + ) + } + + @Test + @DisplayName("a malformed queue frame is dropped without disturbing the stream") + fun malformedQueueIsInert() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + transport.emit("""{"type":"queue","length":-3}""") + transport.emit("""{"type":"queue"}""") + transport.emit("""{"type":"queue","length":1}""") + testScheduler.runCurrent() + + assertEquals(listOf(1), events.filterIsInstance().map { it.length }) + } + + @Test + @DisplayName("a held gate carries the command preview, so the user is not approving blind") + fun gateCarriesCommandPreview() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + transport.emit( + """{"type":"status","status":"waiting","pending":true,"gate":"tool","detail":"Bash", + "preview":{"kind":"command","text":"git push --force","truncated":false}}""", + ) + testScheduler.runCurrent() + + val gate = events.filterIsInstance().last().gate + assertNotNull(gate, "the gate itself must still be emitted") + assertEquals( + ApprovalPreview.Command("git push --force", truncated = false), + gate!!.preview, + "the whole point of W1: show WHAT is being approved, not just the tool name", + ) + } + + @Test + @DisplayName("a preview that arrives a frame late is picked up on the sustained status") + fun previewCanArriveLate() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + // Rising edge with no preview yet — the server's bounded render is not always ready in time. + transport.emit("""{"type":"status","status":"waiting","pending":true,"gate":"tool"}""") + testScheduler.runCurrent() + val risen = events.filterIsInstance().last().gate!! + assertNull(risen.preview) + + transport.emit( + """{"type":"status","status":"waiting","pending":true,"gate":"tool", + "preview":{"kind":"command","text":"ls -la"}}""", + ) + testScheduler.runCurrent() + + val refreshed = events.filterIsInstance().last().gate!! + assertEquals(ApprovalPreview.Command("ls -la"), refreshed.preview) + assertEquals( + risen.epoch, + refreshed.epoch, + "a sustained pending must NOT mint a new epoch — a decision tapped before the preview " + + "landed has to stay valid, or the preview arriving would silently invalidate the tap", + ) + } + + @Test + @DisplayName("a broken preview still yields the gate") + fun brokenPreviewStillGates() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + transport.emit( + """{"type":"status","status":"waiting","pending":true,"gate":"tool","preview":{"kind":"nope"}}""", + ) + testScheduler.runCurrent() + + val gate = events.filterIsInstance().lastOrNull()?.gate + assertNotNull(gate, "losing the gate because its preview was malformed would be the worse bug") + assertNull(gate!!.preview) + } + + @Test + @DisplayName("lifting the gate clears the preview with it") + fun liftingClearsPreview() = runTest { + val transport = FakeTermTransport() + val (_, events) = started(transport) + + transport.emit( + """{"type":"status","status":"waiting","pending":true,"gate":"tool", + "preview":{"kind":"command","text":"rm -rf /"}}""", + ) + testScheduler.runCurrent() + transport.emit("""{"type":"status","status":"working","pending":false}""") + testScheduler.runCurrent() + + assertNull( + events.filterIsInstance().last().gate, + "a resolved gate must not leave a stale command on screen", + ) + assertTrue(events.filterIsInstance().size >= 2) + } +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ApprovalPreview.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ApprovalPreview.kt new file mode 100644 index 0000000..93317f3 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ApprovalPreview.kt @@ -0,0 +1,81 @@ +package wang.yaojia.webterm.wire + +/** + * W1 — a bounded preview of the tool a held approval is asking about + * (`src/types.ts` `ApprovalPreview`, carried on `status.preview`). + * + * ### Why this type has to exist + * Without it a remote approval is **blind**: the gate surfaces could show the tool's *name* and nothing + * else, so the user was being asked to approve a `Bash` command without seeing the command, or an `Edit` + * without seeing the diff. Approving blind is worse than not being able to approve at all — the whole + * point of the remote gate is that the person holding the phone can make an informed call. The server has + * been sending this since W1; Android simply dropped it on the floor. + * + * ### Untrusted, and rendered inert + * Both variants carry attacker-influenced text: a command line and a diff body come from whatever Claude + * Code decided to run against whatever the repo contains. Renderers MUST treat every string here as inert + * (plan §8) — plain text only, no linkification, no Markdown, no autolink. The server already strips + * control/ANSI characters from [Command.text] except newlines, but this client does not depend on that. + * + * @see [ServerMessage.Status.preview] + */ +public sealed interface ApprovalPreview { + + /** The source exceeded the server's line/byte cap and was clipped, so what is shown is incomplete. */ + public val truncated: Boolean + + /** + * A shell command (the `Bash` tool family). Newlines are preserved by the server; every other + * control/ANSI character is stripped before it reaches us. + */ + public data class Command( + val text: String, + override val truncated: Boolean = false, + ) : ApprovalPreview + + /** + * ONE synthetic file diff (the `Edit` / `Write` / `MultiEdit` / `NotebookEdit` family). + * + * The shape mirrors the server's `DiffFile`, but note the deliberate boundary: `:app` owns the full + * diff *viewer* (its own richer model lives with `DiffViewModel`, which is fed by `GET /projects/diff` + * and needs staging/base-mode state this type has no business carrying). This is the read-only subset + * a gate needs in order to answer "what am I approving?", and it lives here because `:wire-protocol` + * is the single place new wire types may be added (CLAUDE.md) and `:session-core` may not depend on + * `:app`. + */ + public data class Diff( + val file: PreviewDiffFile, + override val truncated: Boolean = false, + ) : ApprovalPreview +} + +/** One file's worth of diff inside an [ApprovalPreview.Diff] (`src/types.ts` `DiffFile`). */ +public data class PreviewDiffFile( + val oldPath: String, + val newPath: String, + /** Raw wire value; unknown values are preserved verbatim rather than coerced, so a new server + * status renders as itself instead of silently becoming "modified". */ + val status: String, + val added: Int, + val removed: Int, + val binary: Boolean, + val hunks: List, +) + +/** One hunk (`src/types.ts` `DiffHunk`). */ +public data class PreviewDiffHunk( + val header: String, + val lines: List, +) + +/** + * One diff line (`src/types.ts` `DiffLine`). + * + * [kind] stays a raw wire string for the same reason as [PreviewDiffFile.status]: a gate that mis-colours + * an unknown line kind is a cosmetic bug, but a gate that DROPS a line it cannot classify would hide part + * of what the user is approving. + */ +public data class PreviewDiffLine( + val kind: String, + val text: String, +) diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt index c6ee40b..09d435f 100644 --- a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt @@ -112,6 +112,7 @@ public object MessageCodec { ServerMessageType.EXIT -> decodeExit(root) ServerMessageType.STATUS -> decodeStatus(root) ServerMessageType.TELEMETRY -> decodeTelemetry(root) + ServerMessageType.QUEUE -> decodeQueue(root) null -> null } } @@ -132,7 +133,71 @@ public object MessageCodec { val detail = obj.stringField("detail") val pending = obj.boolField("pending") ?: false val gate = obj.stringField("gate")?.let { GateKind.fromWire(it) } - return ServerMessage.Status(status, detail, pending, gate) + // A malformed preview degrades to null and NEVER fails the frame: the `pending` signal is what + // makes the gate appear at all, so trading a missing preview for a missing gate would be a bad bargain. + val preview = decodePreview(obj["preview"]) + return ServerMessage.Status(status, detail, pending, gate, preview) + } + + private fun decodeQueue(obj: JsonObject): ServerMessage? { + val length = obj.intField("length") ?: return null + // A negative depth is nonsense the UI would render as a badge; drop the frame rather than + // propagate it. The next broadcast carries the real depth, so nothing is lost for long. + if (length < 0) return null + return ServerMessage.Queue(length) + } + + /** + * `status.preview` (W1). Returns null for absent, non-object, unknown-`kind`, or structurally broken + * input — every one of which is a preview we cannot show, not a frame we should discard. + */ + private fun decodePreview(element: kotlinx.serialization.json.JsonElement?): ApprovalPreview? { + val obj = element as? JsonObject ?: return null + val truncated = obj.boolField("truncated") ?: false + return when (obj.stringField("kind")) { + PREVIEW_KIND_COMMAND -> obj.stringField("text")?.let { + ApprovalPreview.Command(text = it, truncated = truncated) + } + PREVIEW_KIND_DIFF -> decodePreviewFile(obj["file"])?.let { + ApprovalPreview.Diff(file = it, truncated = truncated) + } + else -> null + } + } + + private fun decodePreviewFile(element: kotlinx.serialization.json.JsonElement?): PreviewDiffFile? { + val obj = element as? JsonObject ?: return null + // Paths identify WHAT is being changed; without them the preview cannot be trusted to describe + // the right file, so this is the one place the diff variant is dropped rather than degraded. + val oldPath = obj.stringField("oldPath") ?: return null + val newPath = obj.stringField("newPath") ?: return null + return PreviewDiffFile( + oldPath = oldPath, + newPath = newPath, + status = obj.stringField("status") ?: "", + added = obj.intField("added") ?: 0, + removed = obj.intField("removed") ?: 0, + binary = obj.boolField("binary") ?: false, + hunks = (obj["hunks"] as? kotlinx.serialization.json.JsonArray) + ?.mapNotNull { decodePreviewHunk(it) } + .orEmpty(), + ) + } + + private fun decodePreviewHunk(element: kotlinx.serialization.json.JsonElement?): PreviewDiffHunk? { + val obj = element as? JsonObject ?: return null + return PreviewDiffHunk( + header = obj.stringField("header") ?: "", + // Drop-one-keep-rest: a single unparseable line must not cost the user the rest of the diff + // they are being asked to approve. + lines = (obj["lines"] as? kotlinx.serialization.json.JsonArray) + ?.mapNotNull { line -> + val l = line as? JsonObject ?: return@mapNotNull null + val text = l.stringField("text") ?: return@mapNotNull null + PreviewDiffLine(kind = l.stringField("kind") ?: "", text = text) + } + .orEmpty(), + ) } private fun decodeTelemetry(obj: JsonObject): ServerMessage? { @@ -229,4 +294,8 @@ public object MessageCodec { key: String, deserializer: kotlinx.serialization.DeserializationStrategy, ): T? = this[key]?.let { runCatching { json.decodeFromJsonElement(deserializer, it) }.getOrNull() } + + /** `ApprovalPreview` discriminators (`src/types.ts` — a string union, not an enum). */ + private const val PREVIEW_KIND_COMMAND = "command" + private const val PREVIEW_KIND_DIFF = "diff" } diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt index 689efc4..fdb7504 100644 --- a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt @@ -36,10 +36,27 @@ public sealed interface ServerMessage { val detail: String? = null, val pending: Boolean = false, val gate: GateKind? = null, + /** + * W1 — what the held approval is actually asking about. Present only on a held ([pending]) + * waiting status for a Bash/Edit-family tool; null otherwise, which is a NORMAL state and not an + * error (plenty of gates have nothing previewable). A malformed preview also decodes to null + * rather than dropping the whole frame: losing the preview degrades the UI, but losing the frame + * would lose the `pending` signal itself and the gate would never appear. + */ + val preview: ApprovalPreview? = null, ) : ServerMessage /** Latest statusLine telemetry broadcast (B2). */ public data class Telemetry(val telemetry: StatusTelemetry) : ServerMessage + + /** + * W2 — current pending-inject queue depth, broadcast to EVERY attached device so all mirrors show the + * same "N queued" badge (`src/types.ts` `{ type: 'queue'; length: number }`). + * + * Previously this frame had no member here at all, so `decodeServer` returned null for it and + * `SessionEngine` silently discarded every one — the badge could never appear on Android. + */ + public data class Queue(val length: Int) : ServerMessage } /** @@ -77,7 +94,8 @@ public enum class ServerMessageType(public val wire: String) { OUTPUT("output"), EXIT("exit"), STATUS("status"), - TELEMETRY("telemetry"); + TELEMETRY("telemetry"), + QUEUE("queue"); public companion object { public fun fromWire(value: String): ServerMessageType? = entries.firstOrNull { it.wire == value } diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ApprovalPreviewDecodeTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ApprovalPreviewDecodeTest.kt new file mode 100644 index 0000000..6c5da63 --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ApprovalPreviewDecodeTest.kt @@ -0,0 +1,156 @@ +package wang.yaojia.webterm.wire + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * W1 `status.preview` + W2 `queue` decode. + * + * Both of these were silently lost before: `queue` had no `ServerMessageType` member so the whole frame + * was dropped as undecodable, and `preview` was not modelled at all, which made every remote approval + * BLIND — the user was asked to approve a `Bash` call without seeing the command. + * + * The governing rule these tests pin: a broken PREVIEW must never cost us the FRAME. The `pending` flag on + * a status frame is what makes the gate appear; trading a missing preview for a missing gate would be a + * bad bargain, so every malformed-preview case below still yields a `Status`. + */ +@DisplayName("MessageCodec — status.preview (W1) and queue (W2)") +class ApprovalPreviewDecodeTest { + + private fun status(previewJson: String): ServerMessage.Status? = + MessageCodec.decodeServer( + """{"type":"status","status":"working","pending":true,"gate":"tool","detail":"Bash","preview":$previewJson}""", + ) as? ServerMessage.Status + + // ── queue (W2) ─────────────────────────────────────────────────────────────────────────────────── + + @Test + @DisplayName("a queue frame decodes instead of being dropped as unknown") + fun queueDecodes() { + val msg = MessageCodec.decodeServer("""{"type":"queue","length":3}""") + assertEquals(ServerMessage.Queue(3), msg) + } + + @Test + @DisplayName("queue depth zero is a real value, not an absent one") + fun queueZero() { + assertEquals(ServerMessage.Queue(0), MessageCodec.decodeServer("""{"type":"queue","length":0}""")) + } + + @Test + @DisplayName("a negative or missing depth drops the frame rather than feeding nonsense to a badge") + fun queueRejectsNonsense() { + assertNull(MessageCodec.decodeServer("""{"type":"queue","length":-1}""")) + assertNull(MessageCodec.decodeServer("""{"type":"queue"}""")) + assertNull(MessageCodec.decodeServer("""{"type":"queue","length":"3"}"""), "a quoted number is not a number") + } + + // ── preview: command variant ───────────────────────────────────────────────────────────────────── + + @Test + @DisplayName("a command preview decodes, newlines preserved") + fun commandPreview() { + val s = status("""{"kind":"command","text":"rm -rf build\ncd ..","truncated":true}""") + assertEquals( + ApprovalPreview.Command(text = "rm -rf build\ncd ..", truncated = true), + s?.preview, + ) + } + + @Test + @DisplayName("truncated defaults to false when the server omits it") + fun truncatedDefaults() { + val p = status("""{"kind":"command","text":"ls"}""")?.preview + assertEquals(ApprovalPreview.Command("ls", truncated = false), p) + } + + // ── preview: diff variant ──────────────────────────────────────────────────────────────────────── + + @Test + @DisplayName("a diff preview decodes file, hunks and lines") + fun diffPreview() { + val s = status( + """{"kind":"diff","file":{"oldPath":"a.kt","newPath":"a.kt","status":"modified", + "added":2,"removed":1,"binary":false, + "hunks":[{"header":"@@ -1 +1,2 @@","lines":[ + {"kind":"add","text":"new"},{"kind":"del","text":"old"}]}]}}""", + ) + val diff = s?.preview as? ApprovalPreview.Diff + assertNotNull(diff, "the diff variant must decode") + assertEquals("a.kt", diff!!.file.newPath) + assertEquals(2, diff.file.added) + assertEquals(1, diff.file.hunks.size) + assertEquals( + listOf(PreviewDiffLine("add", "new"), PreviewDiffLine("del", "old")), + diff.file.hunks.single().lines, + ) + } + + @Test + @DisplayName("an unknown line kind is kept, not dropped — hiding a line hides what is being approved") + fun unknownLineKindKept() { + val s = status( + """{"kind":"diff","file":{"oldPath":"a","newPath":"a","status":"totally-new-status", + "added":0,"removed":0,"binary":false, + "hunks":[{"header":"h","lines":[{"kind":"future-kind","text":"keep me"}]}]}}""", + ) + val diff = s?.preview as ApprovalPreview.Diff + assertEquals("totally-new-status", diff.file.status, "an unknown status renders as itself") + assertEquals( + listOf(PreviewDiffLine("future-kind", "keep me")), + diff.file.hunks.single().lines, + ) + } + + @Test + @DisplayName("one unparseable line is dropped but its siblings survive") + fun lossyLines() { + val s = status( + """{"kind":"diff","file":{"oldPath":"a","newPath":"a","status":"modified", + "added":0,"removed":0,"binary":false, + "hunks":[{"header":"h","lines":[ + {"kind":"add","text":"first"},{"kind":"add"},{"kind":"add","text":"third"}]}]}}""", + ) + val diff = s?.preview as ApprovalPreview.Diff + assertEquals( + listOf("first", "third"), + diff.file.hunks.single().lines.map { it.text }, + "drop-one-keep-rest: a malformed line must not cost the user the rest of the diff", + ) + } + + // ── degradation: the load-bearing rule ─────────────────────────────────────────────────────────── + + @Test + @DisplayName("every malformed preview degrades to null AND still yields the Status frame") + fun malformedPreviewNeverCostsTheFrame() { + val broken = listOf( + """null""" to "explicit null", + """"a string"""" to "not an object", + """{}""" to "no kind", + """{"kind":"command"}""" to "command without text", + """{"kind":"diff"}""" to "diff without file", + """{"kind":"diff","file":{"newPath":"a"}}""" to "diff file without oldPath", + """{"kind":"video"}""" to "unknown kind", + ) + broken.forEach { (json, why) -> + val s = status(json) + assertNotNull(s, "the frame must survive a bad preview ($why)") + assertNull(s!!.preview, "the preview must degrade to null ($why)") + assertTrue(s.pending, "the pending flag — the reason the gate appears at all — must survive ($why)") + } + } + + @Test + @DisplayName("a status frame with no preview at all is a normal frame") + fun previewAbsentIsNormal() { + val s = MessageCodec.decodeServer("""{"type":"status","status":"waiting","pending":true}""") + as? ServerMessage.Status + assertNotNull(s) + assertNull(s!!.preview) + } +} From 35d32f467002b37a43fd90e2e8793b9978c68609 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 09:45:59 +0200 Subject: [PATCH 03/10] feat(android): decode the v0.6 git-panel surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server shipped the project-detail git panel in 8fe1f52/f711db9/042c4cd, all after the last Android commit, so the client silently ignored every new field (they are additive and optional, so nothing crashed — it just rendered the pre-w6 shape: one bare dirty dot and no sync information at all). Added: SyncState + ProjectDetail.sync/dirtyCount, ProjectInfo.dirtyCount, ProjectSessionRef.cwd, GitLogResult.upstream, CommitLogEntry.unpushed, plus GET /projects/worktree/state (read) and POST /projects/git/fetch (write). Two rules from the server's own commit message are encoded as behaviour rather than left to each call site to remember: `SyncState.isFullySynced(now)` is the only sanctioned way to ask whether the green all-clear may be rendered, and it answers false for anything unknown. ahead/behind compare against @{u}, a locally cached ref that only a fetch moves, so `behind = 0` from a stale FETCH_HEAD proves nothing. Most importantly NO UPSTREAM leaves both counts undefined — which is not zero — and that is the normal state of a fresh worktree branch. The server calls that fall-through the easiest bug to ship here; there is a test per way it could regress, including a fetch timestamp in the future, which is clock skew rather than evidence that anything was checked. `GitLogResult.upstreamBoundaryIndex` finds the LAST unpushed commit rather than assuming the unpushed ones come first. git log is date-ordered, so merging an older branch interleaves unpushed commits below pushed ones, and the row-count shortcut fails in the dangerous direction — it would label an unpushed commit as pushed. It returns null, not -1, when no boundary may be drawn, so "no upstream" cannot be mistaken for "boundary before everything". Origin policy is asserted in both directions: worktree/state carries no Origin, git/fetch carries a byte-equal one, and a test also asserts the fetch body contains no remote or refspec — the remote is derived server-side precisely so a client cannot aim a fetch at an arbitrary URL. Fetch has its own rate-limit bucket, so a 429 is surfaced rather than retried, and a failed fetch leaves lastFetchMs unknown so the UI keeps saying "stale" instead of pretending it refreshed. Verified: :api-client:test + koverVerify green; 22 new tests. --- .../wang/yaojia/webterm/api/models/GitLog.kt | 33 +++- .../yaojia/webterm/api/models/GitWrite.kt | 10 + .../yaojia/webterm/api/models/Projects.kt | 12 ++ .../yaojia/webterm/api/models/SyncState.kt | 88 +++++++++ .../yaojia/webterm/api/routes/ApiClient.kt | 32 ++++ .../yaojia/webterm/api/routes/Endpoints.kt | 26 +++ .../webterm/api/models/SyncStateTest.kt | 141 +++++++++++++++ .../webterm/api/routes/W6RouteShapeTest.kt | 171 ++++++++++++++++++ 8 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt index 5a17990..f1dbb84 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt @@ -14,6 +14,15 @@ public data class CommitLogEntry( val hash: String, val at: Long, val subject: String = "", + /** + * w6/G4: reachable from HEAD but NOT from `@{u}` — i.e. this commit has not been pushed. + * + * Server-computed from `rev-list`, and deliberately NOT "the first N rows": `git log` is + * date-ordered, so merging an older branch interleaves unpushed commits BELOW pushed ones. The + * row-count shortcut fails in the dangerous direction — it would call an unpushed commit pushed. + * Absent ⇒ unknown (no upstream to compare against), which must not render as "pushed". + */ + val unpushed: Boolean? = null, ) /** @@ -25,7 +34,29 @@ public data class GitLogResult( @Serializable(with = CommitLogEntryListSerializer::class) val commits: List = emptyList(), val truncated: Boolean = false, -) + /** + * w6/G4: upstream short name used to label the pushed/unpushed boundary. Null ⇒ nothing to compare + * against, so **no boundary may be drawn at all** — not a boundary drawn at position zero. + */ + val upstream: String? = null, +) { + /** + * Index of the LAST unpushed commit, or null when no boundary may be drawn. + * + * The boundary is drawn ONCE, after this index — never once per unpushed commit, and never at all + * when [upstream] is absent. Returns null rather than -1 so a caller cannot accidentally treat + * "no boundary" as "boundary before everything". + */ + public val upstreamBoundaryIndex: Int? + get() { + if (upstream == null) return null + val last = commits.indexOfLast { it.unpushed == true } + return last.takeIf { it >= 0 } + } + + /** How many commits are waiting to be pushed; 0 when unknown or nothing is pending. */ + public val unpushedCount: Int get() = commits.count { it.unpushed == true } +} /** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */ internal object CommitLogEntryListSerializer : diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt index e543ea1..11e6234 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt @@ -42,6 +42,16 @@ public data class CommitResult(val commit: String = "") @Serializable public data class PushResult(val branch: String? = null, val remote: String? = null) +/** + * `POST /projects/git/fetch` 200 payload (w6/G2). + * + * [lastFetchMs] is FETCH_HEAD's mtime AFTER the fetch, so the UI can stop saying "stale" without + * re-probing. On FAILURE the server deliberately leaves the old value alone, which is why this is + * nullable: a fetch that did not happen must keep reading as stale rather than pretending it refreshed. + */ +@Serializable +public data class FetchResult(val lastFetchMs: Long? = null) + /** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */ @Serializable public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt index a96395d..642fdaf 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt @@ -17,6 +17,12 @@ public data class ProjectSessionRef( val clientCount: Int, val createdAt: Long, val exited: Boolean, + /** + * w6/G7: the session's working directory, needed to attribute it to a worktree. Matching is + * DEEPEST-first, because `.claude/worktrees/` lives INSIDE the main checkout and a prefix + * match would count every worktree session against the parent repo too. + */ + val cwd: String? = null, ) /** @@ -40,6 +46,8 @@ public data class ProjectInfo( val behind: Int? = null, /** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */ val lastCommitMs: Long? = null, + /** w6/G1: porcelain line count, gated the same way as [dirty]. */ + val dirtyCount: Int? = null, @Serializable(with = ProjectSessionRefListSerializer::class) val sessions: List = emptyList(), ) @@ -65,6 +73,10 @@ public data class ProjectDetail( val isGit: Boolean, val branch: String? = null, val dirty: Boolean? = null, + /** w6/G1: porcelain line count, gated the same way as [dirty]. */ + val dirtyCount: Int? = null, + /** w6/G1: git repos only; null for a non-git dir. See [SyncState] for what may be trusted. */ + val sync: SyncState? = null, @Serializable(with = WorktreeInfoListSerializer::class) val worktrees: List = emptyList(), @Serializable(with = ProjectSessionRefListSerializer::class) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt new file mode 100644 index 0000000..09135b5 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SyncState.kt @@ -0,0 +1,88 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.Serializable + +/** + * w6/G1 — how far the checked-out branch has drifted from its upstream (`src/types.ts` `SyncState`). + * + * ## The one rule this whole feature turns on + * `ahead`/`behind` are computed against `@{u}`, a **locally cached** remote ref that only a `fetch` + * moves. That asymmetry decides what may be trusted: + * + * - [ahead] needs only local refs, so it is always trustworthy. + * - [behind] is only meaningful if [lastFetchMs] is recent. A stale FETCH_HEAD reports `behind = 0` + * for a branch that is in fact many commits behind — the server's own commit message records finding + * exactly that (`↓0` while FETCH_HEAD had not moved in 19 days). + * - **No upstream leaves [ahead] and [behind] undefined**, which is NOT the same as zero. A fresh + * worktree branch normally has no upstream, and rendering it as "in sync" is the single easiest bug + * to ship here. [isFullySynced] is the only sanctioned way to ask "may I render the all-clear?" and + * it answers false whenever anything is unknown. Do not re-derive that test at a call site. + * + * Every field is optional because every one of them degrades independently on the server (no upstream, + * detached HEAD, never fetched, not a git repo). + */ +@Serializable +public data class SyncState( + /** e.g. `origin/develop`; null ⇒ the branch tracks nothing. */ + val upstream: String? = null, + /** Commits on HEAD not on `@{u}`. Null ⇒ unknown (no upstream), NOT zero. */ + val ahead: Int? = null, + /** Commits on `@{u}` not on HEAD. Null ⇒ unknown. Trust only when [isFetchFresh]. */ + val behind: Int? = null, + /** FETCH_HEAD mtime in ms; null ⇒ never fetched. */ + val lastFetchMs: Long? = null, + /** HEAD is not on a branch ⇒ no branch, no ahead/behind, and fetch/push are not offerable. */ + val detached: Boolean = false, +) { + + /** True when there is an upstream to compare against at all. */ + public val hasUpstream: Boolean get() = upstream != null + + /** + * True when [lastFetchMs] is recent enough for [behind] to mean anything. + * + * @param nowMs caller-supplied clock so this stays pure and testable. + */ + public fun isFetchFresh(nowMs: Long): Boolean { + val fetched = lastFetchMs ?: return false + val age = nowMs - fetched + // A clock skew that puts the fetch in the future is not evidence of freshness. + return age in 0..FETCH_FRESH_WINDOW_MS + } + + /** True when [behind] should be shown with a "stale — I have not checked" qualifier. */ + public fun isBehindStale(nowMs: Long): Boolean = hasUpstream && !isFetchFresh(nowMs) + + /** + * The ONLY state that may render as the green all-clear: nothing to push, nothing to pull, and a + * fresh enough fetch to justify saying so. + * + * Green means "I checked, you can ignore this". Getting it wrong is lying to the user, so anything + * unknown — no upstream, detached, never fetched, stale fetch, null counts — is false. + */ + public fun isFullySynced(nowMs: Long): Boolean = + hasUpstream && !detached && ahead == 0 && behind == 0 && isFetchFresh(nowMs) + + public companion object { + /** + * How long a fetch stays "fresh". Mirrors the server rule that `behind` is flagged stale once + * FETCH_HEAD is older than an hour (w6/G1). + */ + public const val FETCH_FRESH_WINDOW_MS: Long = 60L * 60L * 1000L + } +} + +/** + * w6/G7 — the git state of ONE worktree, fetched lazily per row via `GET /projects/worktree/state` + * (`src/types.ts` `WorktreeState`). + * + * Deliberately narrower than `ProjectDetail`: N rows must not each pay for a worktree listing and a + * CLAUDE.md read that nothing renders. + */ +@Serializable +public data class WorktreeState( + val path: String, + val branch: String? = null, + val sync: SyncState? = null, + val dirtyCount: Int? = null, +) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt index 60005c4..4e2b0a3 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt @@ -11,12 +11,14 @@ import wang.yaojia.webterm.api.models.PrStatus import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.PruneWorktreesResult +import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult import wang.yaojia.webterm.api.models.SessionPreview import wang.yaojia.webterm.api.models.StageResult import wang.yaojia.webterm.api.models.UiConfig import wang.yaojia.webterm.api.models.UiPrefs +import wang.yaojia.webterm.api.models.WorktreeState import wang.yaojia.webterm.api.models.decodeGitError import wang.yaojia.webterm.api.models.decodeGitPayload import wang.yaojia.webterm.wire.HostEndpoint @@ -206,6 +208,36 @@ public class ApiClient( public suspend fun gitPush(path: String): GitWriteOutcome = gitWrite(Endpoints.gitPush(path), PushResult.serializer()) + /** + * `POST /projects/git/fetch` (w6/G2) — refresh `refs/remotes` so `behind` becomes trustworthy. + * + * Shares the guarded-write dispatch, so a disabled kill-switch or an Origin failure both surface as + * [GitWriteOutcome.Rejected] with the server's safe message. Fetch has its OWN rate-limit bucket on + * the server precisely so refreshes cannot eat the budget a real push needs — surface a 429 rather + * than retrying. + */ + public suspend fun gitFetch(path: String): GitWriteOutcome = + gitWrite(Endpoints.gitFetch(path), FetchResult.serializer()) + + /** + * `GET /projects/worktree/state?path=` (w6/G7) — the per-row git state of ONE worktree. + * + * Mapped like the other project reads. A malformed body throws rather than degrading to an empty + * state: a row that silently claims "clean, in sync" would be a lie, and the caller is expected to + * isolate this failure to its own row (the panel must still render). + */ + public suspend fun worktreeState(path: String): WorktreeState { + if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid + val response = perform(Endpoints.worktreeState(path)) + return when (response.status) { + HttpStatus.OK -> LossyDecode.objectOrNull(response.body, WorktreeState.serializer()) + ?: throw ApiClientError.InvalidResponseBody + HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid + HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound + else -> throw ApiClientError.UnexpectedStatus(response.status) + } + } + /** * Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the * decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected] diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt index 21ac018..eb27f8f 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt @@ -54,6 +54,19 @@ internal object Endpoints { percentEncodedQuery = "path=${percentEncode(path)}", ) + /** + * `GET /projects/worktree/state?path=` — RO (w6/G7). Deliberately NARROWER than + * `/projects/detail`: it is fetched once per worktree row, so N rows must not each pay for a + * worktree listing and a CLAUDE.md read that nothing renders. + */ + fun worktreeState(path: String): ApiRoute = + ApiRoute( + HttpMethod.GET, + "/projects/worktree/state", + OriginPolicy.READ_ONLY, + percentEncodedQuery = "path=${percentEncode(path)}", + ) + fun getPrefs(): ApiRoute = ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY) @@ -148,6 +161,16 @@ internal object Endpoints { fun gitCommit(path: String, message: String): ApiRoute = jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message)) + /** + * `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates + * `refs/remotes`: it is state-changing and spawns a network git process, so it goes through the + * single Origin-stamping point like every other write. The remote is derived SERVER-side and no + * remote or refspec is ever read from the body, so a client cannot aim it at an arbitrary URL. + * It is NOT a pull: no working tree, no index, no merge. + */ + fun gitFetch(path: String): ApiRoute = + jsonBodyRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path)) + /** `POST /projects/git/push` — `{ path }`. */ fun gitPush(path: String): ApiRoute = jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path)) @@ -216,4 +239,7 @@ internal object Endpoints { @Serializable private data class PushBody(val path: String) + + @kotlinx.serialization.Serializable + private data class FetchBody(val path: String) } diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt new file mode 100644 index 0000000..78bce28 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/SyncStateTest.kt @@ -0,0 +1,141 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.json.Json +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.DisplayName +import org.junit.jupiter.api.Test + +/** + * w6/G1 sync state. + * + * The load-bearing rule, quoted from the server commit that introduced the feature: *"Exactly ONE state + * may render green: ↑0 ↓0 AND a fresh fetch. Green means 'I checked, ignore this'; getting it wrong is + * lying to the user."* Most of this file exists to pin the ways that could go wrong — above all the + * no-upstream fall-through, which the server's own commit message calls "the easiest bug to ship here". + */ +@DisplayName("SyncState — when the all-clear may be rendered") +class SyncStateTest { + + private val json = Json { ignoreUnknownKeys = true } + private val now = 1_700_000_000_000L + private fun fresh() = now - 60_000L // a minute ago + private fun stale() = now - (SyncState.FETCH_FRESH_WINDOW_MS + 60_000L) + + @Test + @DisplayName("decodes the full server shape") + fun decodesFull() { + val s = json.decodeFromString( + """{"upstream":"origin/develop","ahead":9,"behind":0,"lastFetchMs":$now,"detached":false}""", + ) + assertEquals("origin/develop", s.upstream) + assertEquals(9, s.ahead) + assertEquals(0, s.behind) + } + + @Test + @DisplayName("every field is optional — each degrades independently on the server") + fun decodesEmpty() { + val s = json.decodeFromString("{}") + assertNull(s.upstream) + assertNull(s.ahead) + assertNull(s.behind) + assertNull(s.lastFetchMs) + assertFalse(s.detached) + assertFalse(s.hasUpstream) + } + + // ── the green state ────────────────────────────────────────────────────────────────────────────── + + @Test + @DisplayName("↑0 ↓0 with a fresh fetch is the ONLY green state") + fun theOneGreenState() { + val s = SyncState(upstream = "origin/main", ahead = 0, behind = 0, lastFetchMs = fresh()) + assertTrue(s.isFullySynced(now)) + } + + @Test + @DisplayName("NO UPSTREAM is never green — the fall-through the server calls the easiest bug here") + fun noUpstreamIsNeverGreen() { + // The normal state of a fresh worktree branch. ahead/behind are UNDEFINED, not zero. + val s = SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh()) + assertFalse(s.isFullySynced(now), "a branch tracking nothing has not been verified as in sync") + assertFalse(s.hasUpstream) + } + + @Test + @DisplayName("undefined counts are not zero, even with an upstream and a fresh fetch") + fun undefinedCountsAreNotZero() { + assertFalse(SyncState("origin/main", ahead = null, behind = 0, lastFetchMs = fresh()).isFullySynced(now)) + assertFalse(SyncState("origin/main", ahead = 0, behind = null, lastFetchMs = fresh()).isFullySynced(now)) + } + + @Test + @DisplayName("a stale fetch is not green — behind=0 from a stale FETCH_HEAD proves nothing") + fun staleFetchIsNotGreen() { + val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = stale()) + assertFalse(s.isFullySynced(now)) + assertTrue(s.isBehindStale(now)) + } + + @Test + @DisplayName("never fetched is not green") + fun neverFetchedIsNotGreen() { + val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = null) + assertFalse(s.isFullySynced(now)) + assertFalse(s.isFetchFresh(now)) + } + + @Test + @DisplayName("a detached HEAD is not green") + fun detachedIsNotGreen() { + val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = fresh(), detached = true) + assertFalse(s.isFullySynced(now)) + } + + @Test + @DisplayName("anything unpushed or unpulled is not green") + fun pendingWorkIsNotGreen() { + assertFalse(SyncState("origin/main", ahead = 1, behind = 0, lastFetchMs = fresh()).isFullySynced(now)) + assertFalse(SyncState("origin/main", ahead = 0, behind = 1, lastFetchMs = fresh()).isFullySynced(now)) + } + + // ── freshness edges ────────────────────────────────────────────────────────────────────────────── + + @Test + @DisplayName("a fetch timestamp in the future is not treated as fresh") + fun futureFetchIsNotFresh() { + val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now + 60_000L) + assertFalse(s.isFetchFresh(now), "clock skew is not evidence that we checked") + assertFalse(s.isFullySynced(now)) + } + + @Test + @DisplayName("the freshness boundary is inclusive at exactly the window") + fun boundaryInclusive() { + val atEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS) + assertTrue(atEdge.isFetchFresh(now)) + val pastEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS - 1) + assertFalse(pastEdge.isFetchFresh(now)) + } + + @Test + @DisplayName("staleness is only claimed when there is an upstream to be stale about") + fun noUpstreamIsNotStale() { + assertFalse(SyncState(upstream = null, lastFetchMs = null).isBehindStale(now)) + } + + @Test + @DisplayName("WorktreeState decodes the narrow per-row shape") + fun worktreeStateDecodes() { + val w = json.decodeFromString( + """{"path":"/repo/.claude/worktrees/x","branch":"wt-x","dirtyCount":3, + "sync":{"upstream":"origin/wt-x","ahead":2}}""", + ) + assertEquals("wt-x", w.branch) + assertEquals(3, w.dirtyCount) + assertEquals(2, w.sync?.ahead) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt new file mode 100644 index 0000000..185108d --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/W6RouteShapeTest.kt @@ -0,0 +1,171 @@ +package wang.yaojia.webterm.api.routes + +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.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.GitLogResult +import wang.yaojia.webterm.api.models.GitWriteOutcome +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest + +/** + * Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the two routes the v0.6 git panel added + * after the last Android commit: `GET /projects/worktree/state` (read) and `POST /projects/git/fetch` + * (write). A route reclassified read↔write turns this red rather than silently shipping either a CSWSH + * hole or a write the server will 403. + * + * Also pins the commit-log boundary arithmetic, which the server's own commit warns "fails in the + * dangerous direction" if shortcut to "the first N rows". + */ +@DisplayName("w6 routes — shape, Origin policy, and the unpushed boundary") +class W6RouteShapeTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val ORIGIN = "http://192.168.1.5:3000" + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + + private fun last(): HttpRequest = transport.recordedRequests.last() + + // ── GET /projects/worktree/state — read-only, no Origin ────────────────────────────────────────── + + @Test + fun `worktreeState is a read-only GET with a strict-encoded path and no Origin`() = runTest { + val path = "/home/me/my repo/a+b&c" + transport.queueSuccess( + HttpMethod.GET, + "$BASE/projects/worktree/state?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c", + 200, + body = """{"path":"$path","branch":"wt","dirtyCount":2}""".toByteArray(), + ) + + val state = client.worktreeState(path) + + val r = last() + assertEquals(HttpMethod.GET, r.method) + assertFalse( + r.headers.containsKey(HeaderName.ORIGIN), + "a read must NOT stamp Origin — Origin is the CSWSH marker for state-changing routes only", + ) + assertTrue(r.url.contains("%2Bb"), "a bare + would decode to a SPACE in Express's qs parser") + assertEquals(2, state.dirtyCount) + } + + // ── POST /projects/git/fetch — guarded, byte-equal Origin, body carries only the path ──────────── + + @Test + fun `gitFetch is a guarded POST that stamps a byte-equal Origin`() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true,"lastFetchMs":42}""".toByteArray()) + + val outcome = client.gitFetch("/repo") + + val r = last() + assertEquals(HttpMethod.POST, r.method) + assertEquals( + ORIGIN, + r.headers[HeaderName.ORIGIN], + "fetch is state-changing (it moves refs/remotes and spawns a network git), so it must be guarded", + ) + assertNotNull(r.body, "the path travels in a JSON body") + val body = r.body!!.decodeToString() + assertTrue(body.contains("\"path\"")) + assertFalse( + body.contains("remote") || body.contains("refspec"), + "the remote is derived SERVER-side; a client must never be able to aim a fetch at an arbitrary URL", + ) + assertEquals(42L, (outcome as GitWriteOutcome.Ok).payload.lastFetchMs) + } + + @Test + fun `a fetch that fails leaves lastFetchMs unknown rather than claiming it refreshed`() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true}""".toByteArray()) + + val outcome = client.gitFetch("/repo") + + assertNull( + (outcome as GitWriteOutcome.Ok).payload.lastFetchMs, + "the server leaves the old value alone on failure, so the UI must keep saying stale", + ) + } + + @Test + fun `fetch surfaces its own rate limit instead of retrying`() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 429, body = """{"error":"slow down"}""".toByteArray()) + + val outcome = client.gitFetch("/repo") + + assertTrue( + outcome is GitWriteOutcome.RateLimited, + "fetch has its OWN bucket so refreshes cannot eat the budget a real push needs", + ) + } + + @Test + fun `a disabled or Origin-rejected fetch surfaces the server's safe message`() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 403, body = """{"error":"git ops disabled"}""".toByteArray()) + + val outcome = client.gitFetch("/repo") + + // 403 is overloaded — kill-switch AND Origin failure both return it — so the message is + // surfaced rather than guessed at with a typed variant. + assertEquals("git ops disabled", (outcome as GitWriteOutcome.Rejected).message) + } + + // ── the unpushed boundary (w6/G4) ──────────────────────────────────────────────────────────────── + + @Test + @DisplayName("the boundary sits after the LAST unpushed commit, even when they interleave") + fun boundaryHandlesInterleaving() { + // A backdated merge puts an unpushed commit BELOW a pushed one, because git log is date-ordered. + // "The first N rows" would mark the wrong set and call an unpushed commit pushed. + val log = GitLogResult( + commits = listOf( + commit("aaa", unpushed = true), + commit("bbb", unpushed = false), + commit("ccc", unpushed = true), + commit("ddd", unpushed = false), + ), + upstream = "origin/main", + ) + assertEquals(2, log.upstreamBoundaryIndex, "after index 2 — the last unpushed one") + assertEquals(2, log.unpushedCount) + } + + @Test + @DisplayName("no upstream means NO boundary may be drawn at all") + fun noUpstreamNoBoundary() { + val log = GitLogResult(commits = listOf(commit("aaa", unpushed = true)), upstream = null) + assertNull( + log.upstreamBoundaryIndex, + "null rather than -1, so a caller cannot mistake 'no boundary' for 'boundary before everything'", + ) + } + + @Test + @DisplayName("nothing unpushed means no boundary") + fun nothingUnpushed() { + val log = GitLogResult(commits = listOf(commit("aaa", unpushed = false)), upstream = "origin/main") + assertNull(log.upstreamBoundaryIndex) + assertEquals(0, log.unpushedCount) + } + + @Test + @DisplayName("an unknown unpushed flag is not counted as pushed") + fun unknownIsNotPushed() { + val log = GitLogResult(commits = listOf(commit("aaa", unpushed = null)), upstream = "origin/main") + assertEquals(0, log.unpushedCount, "unknown is not 'unpushed'…") + assertNull(log.upstreamBoundaryIndex, "…and it must not invent a boundary either") + } + + private fun commit(hash: String, unpushed: Boolean?) = + wang.yaojia.webterm.api.models.CommitLogEntry(hash = hash, at = 1L, subject = "s", unpushed = unpushed) +} From 980dbaa928438b667532be94a8df65db3a538916 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 11:23:23 +0200 Subject: [PATCH 04/10] feat(android): wire the dead code, render the git panel, make the token gate real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four agents' worth of :app wiring. Everything here existed as tested code with zero production call sites, or as a screen rendering a shape the server stopped sending months ago. Dead code that shipped as features. All three were fully implemented and unit-tested while being reachable from nothing: - Session-list preview thumbnails. SessionsHome called SessionListScreen without the `thumbnails` argument, so the `= null` default won and every row rendered a placeholder forever — on the home session chooser, whose entire purpose is seeing what each session is doing. - Quick-reply chips and their palette editor. - PointerContextMenu (large-screen secondary click). ThumbnailPipeline's formal cross-review had been explicitly skipped when it was written — PROGRESS_ANDROID.md said so in as many words, and it is concurrency code, so nothing had ever reviewed OR executed it. It has now been reviewed and kept: the LruCache key, the fair Semaphore(2), the Mutex-guarded in-flight dedup and the NonCancellable in-flight release are all sound, and a retained map entry would have poisoned a key forever. The v0.6 project-detail git panel. Android rendered the pre-w6 shape: one bare dirty dot, a bare hash+subject commit row, and a worktree heading that renamed itself to "Branch" at n=1 — the exact behaviour G5 removed. Now the sync band, unpushed marking with a single upstream boundary, and "Worktrees (n)". The rule that drives it is enforced, not documented: exactly one state may render green, and no-upstream is not that state. Remote approvals are no longer blind. status.preview reaches GateBanner and PlanGateSheet, so the user can see the command or diff they are approving instead of a tool name. Absent preview stays an ordinary gate, not an error. The WEBTERM_TOKEN gate is no longer inert. NetworkModule was calling OkHttpClientFactory.create(identityProvider) with no cookieJar, so the shared client ran on CookieJar.NO_COOKIES and none of the cookie plumbing did anything. It now installs the real AuthCookieJar — one client, so the cookie rides the WS upgrade too — backed by the Tink-sealed store, and binds InMemoryAuthCookieStore if a cipher cannot be constructed rather than ever persisting a shell credential in the clear. The persister runs on an IO scope because OkHttp calls a CookieJar synchronously and must never block on DataStore or Tink. And the user-facing half that made the gate unusable: a token-gated host answers the pairing probe's unauthenticated GET with 401, which the frozen taxonomy could only report as "this port is running something else" — so such a host was impossible to pair AND the copy was misleading. Pairing now routes to a token prompt and resumes through the same cert-gate choke point. The token is a parameter, never a field and never part of any UI state. Docs corrected rather than left aspirational: the plan asserted a cleartext allowlist "permitted only for private LAN/Tailscale CIDRs", which the platform cannot express at all — the network-security-config format has no netmask or prefix syntax. PROGRESS_ANDROID.md now opens with what "COMPLETE" actually meant, and PROGRESS_LOG.md records the whole pass including what is still undone: A34/A35 have no code, and device QA remains ~0%. Verified: ./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify -> BUILD SUCCESSFUL, 893 JVM tests, 0 failures (baseline was 617). The :app run was re-measured with outputs deleted and --no-build-cache, and the APK re-packaged, so Hilt graph validity is an observation rather than an up-to-date claim. --- android/PROGRESS_ANDROID.md | 74 ++++ .../yaojia/webterm/components/GateBanner.kt | 128 +++++++ .../webterm/components/PlanGateSheet.kt | 5 + .../webterm/components/PointerContextMenu.kt | 47 ++- .../yaojia/webterm/components/QuickReply.kt | 265 ++++++++++++++ .../yaojia/webterm/di/AuthCookieModule.kt | 136 +++++++ .../wang/yaojia/webterm/di/NetworkModule.kt | 20 +- .../wang/yaojia/webterm/di/ThumbnailModule.kt | 31 ++ .../wang/yaojia/webterm/nav/SessionsHome.kt | 56 +++ .../yaojia/webterm/screens/PairingScreen.kt | 92 ++++- .../webterm/screens/ProjectDetailScreen.kt | 344 ++++++++++++++++-- .../webterm/screens/SessionListScreen.kt | 54 ++- .../yaojia/webterm/screens/TerminalScreen.kt | 194 ++++++++-- .../webterm/viewmodels/GateViewModel.kt | 26 +- .../webterm/viewmodels/PairingViewModel.kt | 263 ++++++++++--- .../viewmodels/ProjectDetailViewModel.kt | 314 ++++++++++++++++ .../webterm/viewmodels/ProjectsViewModel.kt | 16 + .../yaojia/webterm/wiring/AppEnvironment.kt | 288 ++++++++++++++- .../wiring/TerminalSessionControllerImpl.kt | 6 +- .../wiring/TerminalThumbnailRasterizer.kt | 78 +++- .../webterm/wiring/ThumbnailPipeline.kt | 121 ++++-- .../webterm/components/QuickReplyTest.kt | 103 ++++++ .../yaojia/webterm/nav/SessionRowMenuTest.kt | 89 +++++ .../screens/TerminalReclaimPolicyTest.kt | 67 ++++ .../webterm/viewmodels/FakeWorktreeGateway.kt | 16 + .../webterm/viewmodels/GatePreviewTest.kt | 192 ++++++++++ .../webterm/viewmodels/GitSyncPanelTest.kt | 275 ++++++++++++++ .../viewmodels/ProjectDetailFetchTest.kt | 177 +++++++++ .../viewmodels/ProjectsViewModelTest.kt | 2 + .../webterm/wiring/AccessTokenGatewayTest.kt | 183 ++++++++++ .../webterm/wiring/AuthCookieHydratorTest.kt | 174 +++++++++ .../webterm/wiring/AuthCookieMappingTest.kt | 62 ++++ .../webterm/wiring/PairingTokenFlowTest.kt | 303 +++++++++++++++ .../webterm/wiring/ThumbnailPipelineTest.kt | 169 ++++++++- docs/ANDROID_CLIENT_PLAN.md | 21 +- docs/PROGRESS_LOG.md | 26 ++ 36 files changed, 4249 insertions(+), 168 deletions(-) create mode 100644 android/app/src/main/java/wang/yaojia/webterm/di/AuthCookieModule.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/di/ThumbnailModule.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/nav/SessionRowMenuTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/screens/TerminalReclaimPolicyTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/GatePreviewTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/GitSyncPanelTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailFetchTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieHydratorTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieMappingTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt diff --git a/android/PROGRESS_ANDROID.md b/android/PROGRESS_ANDROID.md index 4a3a23c..8abad86 100644 --- a/android/PROGRESS_ANDROID.md +++ b/android/PROGRESS_ANDROID.md @@ -372,6 +372,80 @@ warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid --- +## ⚠️ CORRECTION (2026-07-30): "COMPLETE" below meant COMPILES, not WORKS + +The section that follows claimed all 36 tasks had landed. That was true at the level it was measured — +the modules built, the APK assembled and ~484 JVM tests were green — but NOTHING had ever run on an +Android device or emulator, and the first thing that would have happened on one is a crash. An audit on +2026-07-30 found, and a repair pass fixed, the following. Read this before trusting anything below. + +**Three defects made the app unusable on real hardware.** Each was invisible to the JVM suite for the +same structural reason: no JVM test instantiates an Android `View`. + +1. **Every key press crashed.** `RemoteTerminalView` bound the forked emulator but never installed a + `TerminalViewClient`. The pinned Termux `TerminalView` 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`). The KDoc claimed "A17 sets it" — A17 never did; only the + `HardwareKeyRouter` and the KeyBar shipped, and the KeyBar works precisely because it bypasses the + view. Installing a client is not sufficient: returning false continues into `mTermSession.write()` + at offset 150, and `mTermSession` is null forever (`TerminalSession` is final and forking a process + is exactly what this app must not do, §6.1). Fixed by a client that consumes the key itself, with + `KeyRouting` deliberately having NO defer-to-stock branch, so the crash is unrepresentable rather + than merely avoided. `KeyHandler` remains the authority for the key tables, so DECCKM still emits + `ESC O A`. +2. **No `resize` was ever sent.** `RemoteTerminalSession.updateSize` had zero call sites and the stock + fallback is dead (`TerminalView.updateSize` early-returns without a session), so the PTY stayed at + the server's 80x24 and every full-screen TUI rendered into the wrong box. `TerminalResizeDriver` + now drives it from real cell metrics, read via the PUBLIC `TerminalRenderer.getFontWidth()` / + `getFontLineSpacing()` accessors — no reflection (silently breaks under R8) and no re-measured + `Paint` (drifts from what the renderer actually draws, the §R5 risk). +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://`. Its own comment + promised a CIDR allowlist the platform cannot express. See plan §6.9 for the corrected posture. + +**Adversarial review then found three more, and got one wrong:** + + - **Swipe-scrolling inside an alternate-screen TUI still crashed.** The touch path bypasses + `TerminalViewClient` entirely: `doScroll` converts a scroll to `handleKeyCode` whenever the + alternate buffer is active, which dereferences `mTermSession`. Claude Code IS an alternate-screen + TUI, so this was a crash during ordinary use on the app's main screen. `TerminalScrollGesture` now + 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 the app. The subtree is excluded from the autofill + structure. + - The review also demanded stock text selection be RESTORED after the focus rework made it + unreachable. It cannot be: the ActionMode's own Copy and Paste handlers dereference the absent + session (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136), so a + reachable selection UI has an NPE on its Copy button. **Long press stays consumed and copy-out is + a recorded FEATURE GAP** — a first-party selection layer over `TerminalBuffer` + `ClipboardManager` + is the real fix. This is the one place the reviewer was wrong and the builder right. + +**Silently-lost server data, now decoded:** the W2 `queue` frame had no `ServerMessageType` member so +every one was dropped as undecodable (the "N queued" badge could never appear), and W1 +`status.preview` was not modelled at all, which made remote approvals **BLIND** — the user was asked to +approve a `Bash` call without seeing the command. Both now decode, with the rule that a broken preview +never costs the frame (`pending` is what makes the gate appear). + +**Dead code that shipped as features:** `ThumbnailPipeline` (session-list previews), `QuickReply` +(chips + palette) and `PointerContextMenu` were fully implemented and unit-tested with ZERO production +call sites. All three are now wired. `ThumbnailPipeline`'s formal cross-review — explicitly skipped at +the time, as the AW3 entry below admits — has finally been run. + +**Credential handling:** the `WEBTERM_TOKEN` cookie is now carried on REST and the WS upgrade, 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 out of Google +Drive and D2D transfer. + +**Still not done — do not read the section below as saying otherwise:** + - **A34 / A35 have no code.** There is no `:app` androidTest source set and no benchmark module; the + AW6 entry marking them `[x]` reflects them being converted into checklist bullets, not implemented. + - **Device QA remains ~0%.** One emulator smoke screenshot of the pairing screen is the only on-device + evidence in the repo. Every fix above is JVM-verified only. + - Release signing, versioning and minification; the S2 FCM real-handset spike; copy-out via selection. + +--- + ## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1–A36 + S1) landed Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt b/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt index b1f6a3f..76edfa1 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt @@ -16,10 +16,15 @@ import wang.yaojia.webterm.designsystem.DisplayStatus import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.StatusBadge import wang.yaojia.webterm.designsystem.WebTermCard +import wang.yaojia.webterm.designsystem.WebTermColors import wang.yaojia.webterm.designsystem.WebTermTheme +import wang.yaojia.webterm.designsystem.WebTermType import wang.yaojia.webterm.session.GateState import wang.yaojia.webterm.viewmodels.GateDecision +import wang.yaojia.webterm.viewmodels.inertText +import wang.yaojia.webterm.wire.ApprovalPreview import wang.yaojia.webterm.wire.GateKind +import wang.yaojia.webterm.wire.PreviewDiffFile /** * # GateBanner (A22) — the tool-gate two-button approve/reject card. @@ -44,6 +49,7 @@ public fun GateBanner( gate: GateState, onDecide: (GateDecision, Int) -> Unit, modifier: Modifier = Modifier, + preview: ApprovalPreview? = null, ) { WebTermCard(modifier = modifier.fillMaxWidth()) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) { @@ -63,6 +69,9 @@ public fun GateBanner( ) } } + // WHAT is being approved sits between the label and the buttons, so the decision and the + // evidence are never on different screens. Absent → the name-only bar, unchanged. + ApprovalPreviewBlock(preview) Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) { Text(text = "拒绝") @@ -75,6 +84,124 @@ public fun GateBanner( } } +// ── W1: the approval preview (shared by GateBanner and PlanGateSheet) ─────────── + +/** How many diff/command lines a gate surface paints before it says "truncated". The server already + * caps at 40 lines / 4 KB; this is the on-phone reading limit, so the buttons stay reachable. */ +private const val PREVIEW_MAX_VISIBLE_LINES = 20 + +/** + * W1 — render WHAT a held approval would do: the shell command, or the one-file diff. + * + * Everything here is **untrusted** (server-derived from the hook's `tool_input`, which Claude and the + * repo contents influence) and therefore INERT: plain [Text] only — no Markdown, no autolink, no + * `AnnotatedString` link detection (plan §8). A `null` preview renders NOTHING: absent is a normal + * state (plan gates and unknown tools have nothing reviewable), never an error. + */ +@Composable +internal fun ApprovalPreviewBlock(preview: ApprovalPreview?, modifier: Modifier = Modifier) { + if (preview == null) return + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Spacing.xs2), + ) { + when (preview) { + is ApprovalPreview.Command -> CommandPreview(preview) + is ApprovalPreview.Diff -> DiffPreview(preview.file) + } + if (preview.truncated) { + Text( + text = GatePreviewCopy.TRUNCATED, + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun CommandPreview(preview: ApprovalPreview.Command) { + // Newlines are content in a shell command, so the lines are kept and rendered as lines. The + // sanitizer drops every other control byte (defence in depth over the server's own stripping). + val lines = (preview.inertText() ?: return).lines() + for (line in lines.take(PREVIEW_MAX_VISIBLE_LINES)) { + Text( + text = line, + style = WebTermType.monoTabular(12), + color = MaterialTheme.colorScheme.onSurface, + ) + } + if (lines.size > PREVIEW_MAX_VISIBLE_LINES) ClippedNote() +} + +@Composable +private fun DiffPreview(file: PreviewDiffFile) { + Text( + text = GatePreviewCopy.diffHeader(file), + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (file.binary) { + Text(text = GatePreviewCopy.BINARY, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + var painted = 0 + for (hunk in file.hunks) { + if (painted >= PREVIEW_MAX_VISIBLE_LINES) break + DiffLineText(hunk.header, DIFF_KIND_HUNK) + painted++ + for (line in hunk.lines) { + if (painted >= PREVIEW_MAX_VISIBLE_LINES) break + DiffLineText(line.text, line.kind) + painted++ + } + } + val total = file.hunks.sumOf { it.lines.size + 1 } + if (total > painted) ClippedNote() +} + +@Composable +private fun DiffLineText(text: String, kind: String) { + // An UNKNOWN kind still renders (just uncoloured): dropping a line the client cannot classify + // would hide part of what the user is approving. + val color = when (kind) { + DIFF_KIND_ADDED -> WebTermColors.statusWorking + DIFF_KIND_REMOVED -> WebTermColors.statusStuck + DIFF_KIND_HUNK, DIFF_KIND_META -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurface + } + Text(text = text, style = WebTermType.monoTabular(12), color = color, maxLines = 1, overflow = TextOverflow.Ellipsis) +} + +@Composable +private fun ClippedNote() { + Text( + text = GatePreviewCopy.CLIPPED, + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** Wire `DiffLineKind` values (`src/types.ts`) — raw strings, so an unknown kind stays itself. */ +private const val DIFF_KIND_ADDED = "added" +private const val DIFF_KIND_REMOVED = "removed" +private const val DIFF_KIND_HUNK = "hunk" +private const val DIFF_KIND_META = "meta" + +/** Preview copy (local UI text). */ +internal object GatePreviewCopy { + const val TRUNCATED: String = "… 服务端已截断" + const val CLIPPED: String = "… 仅显示前 $PREVIEW_MAX_VISIBLE_LINES 行" + const val BINARY: String = "二进制文件" + + fun diffHeader(file: PreviewDiffFile): String { + val path = if (file.oldPath != file.newPath) "${file.oldPath} → ${file.newPath}" else file.newPath + return "$path +${file.added}/-${file.removed}" + } +} + // ── Preview ───────────────────────────────────────────────────────────────────── @Preview(name = "GateBanner") @@ -84,6 +211,7 @@ private fun GateBannerPreview() { GateBanner( gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3), onDecide = { _, _ -> }, + preview = ApprovalPreview.Command("rm -rf build/\nnpm run build", truncated = true), ) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt b/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt index 1350cbb..396620f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt @@ -20,6 +20,7 @@ import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.WebTermTheme import wang.yaojia.webterm.session.GateState import wang.yaojia.webterm.viewmodels.GateDecision +import wang.yaojia.webterm.wire.ApprovalPreview import wang.yaojia.webterm.wire.GateKind /** @@ -49,6 +50,7 @@ public fun PlanGateSheet( onDecide: (GateDecision, Int) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, + preview: ApprovalPreview? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { @@ -69,6 +71,9 @@ public fun PlanGateSheet( overflow = TextOverflow.Ellipsis, ) } + // W1: a plan gate usually has nothing reviewable, but when the server DOES send a preview + // (a plan that resolves straight into an Edit/Bash) it belongs above the buttons. + ApprovalPreviewBlock(preview) Button( onClick = { onDecide(GateDecision.APPROVE, gate.epoch) }, modifier = Modifier.fillMaxWidth(), diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt b/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt index 0f19099..3a397e3 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.DpOffset import wang.yaojia.webterm.nav.LayoutMode import wang.yaojia.webterm.nav.PointerMenuPolicy +import wang.yaojia.webterm.viewmodels.SessionRow +import java.util.UUID /** * # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu. @@ -59,7 +61,12 @@ public fun PointerContextMenu( val density = LocalDensity.current Box( - modifier = modifier.pointerInput(actions) { + // Keyed on Unit, NOT on [actions]: the gesture body only records the anchor and opens the menu — + // it never reads [actions] (the DropdownMenu below reads the current list on every composition). + // Keying on the list would restart the pointer handler on every recomposition, because the + // per-entry lambdas in a freshly-built List are never equal to the previous + // ones — a right-click landing during that restart window would be dropped. + modifier = modifier.pointerInput(Unit) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() @@ -95,3 +102,41 @@ public fun PointerContextMenu( /** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */ public data class ContextMenuAction(val label: String, val onClick: () -> Unit) + +// ── The session-row action set (A26 — the one surface the pointer menu is attached to) ─────────────── + +/** 打开会话 — the same target a row tap has. */ +public const val MENU_LABEL_OPEN: String = "打开会话" + +/** 在当前目录开新会话 — `attach(null, cwd)` in the row's own cwd (plan §1 new-session-in-cwd). */ +public const val MENU_LABEL_NEW_IN_CWD: String = "在当前目录开新会话" + +/** 终止会话 — `DELETE /live-sessions/:id`, the pointer equivalent of swipe-to-kill. */ +public const val MENU_LABEL_KILL: String = "终止会话" + +/** + * The [PointerContextMenu] entries for one session row — the pure half of the A26 pointer menu (the + * gesture and the placement are device-QA; [PointerMenuPolicy] gates whether the menu exists at all). + * + * Mirrors iOS's pointer menu with ONE deliberate omission: **copy is absent**. Copy-out lives in the + * terminal's own text-selection `ActionMode`, not in a list-row menu, and the row has no selected text to + * copy — advertising it would be a dead entry. + * + * The new-session entry appears only when BOTH a usable cwd is known AND a spawn handler is wired: the + * server may omit `cwd` (or send blank), and `attach(null, cwd)` with no directory is not the same action. + * Every label here is a fixed app string — the server-provided cwd is carried in the CALLBACK only and is + * never rendered into a menu entry (plan §8: untrusted server strings stay out of chrome). + */ +public fun sessionRowMenuActions( + row: SessionRow, + onOpen: (UUID) -> Unit, + onNewSessionInCwd: ((String) -> Unit)?, + onKill: (UUID) -> Unit, +): List = buildList { + add(ContextMenuAction(MENU_LABEL_OPEN) { onOpen(row.id) }) + val cwd = row.info.cwd?.takeIf { it.isNotBlank() } + if (cwd != null && onNewSessionInCwd != null) { + add(ContextMenuAction(MENU_LABEL_NEW_IN_CWD) { onNewSessionInCwd(cwd) }) + } + add(ContextMenuAction(MENU_LABEL_KILL) { onKill(row.id) }) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt b/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt index 7be9f9a..e67d4f0 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt @@ -2,20 +2,35 @@ package wang.yaojia.webterm.components import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import wang.yaojia.webterm.designsystem.Radius import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.WebTermTheme @@ -96,6 +111,241 @@ internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) { internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean = gateHeld && hasChips +// ── The palette presenter (production state holder) ───────────────────────────── + +/** + * The state holder the terminal screen renders the chips from: one published, immutable + * [chips] snapshot over a [QuickReplyStore]. + * + * Every mutation delegates to the store — which is the CRUD authority and returns the new + * list — and republishes exactly what came back, so the UI can never drift from what was + * persisted (no optimistic local copy to reconcile, and a guarded no-op like a blank `add` + * simply republishes the unchanged list). Nothing is published before [load]: an unloaded + * palette shows no chips rather than flashing the defaults over the user's real list. + * + * Plain (not an `androidx.lifecycle.ViewModel`) so it drives under `runTest` virtual time; + * the screen `remember`s one and loads it in a `LaunchedEffect`. Cross-device sync is out of + * scope (plan §1) — this is one device's palette. + */ +public class QuickReplyPalette(private val store: QuickReplyStore) { + private val _chips = MutableStateFlow>(emptyList()) + + /** The palette in display order; empty until [load] (and after a user "delete all"). */ + public val chips: StateFlow> = _chips.asStateFlow() + + /** Read the persisted palette (defaults on first ever read) and publish it. */ + public suspend fun load() { + _chips.value = store.loadAll() + } + + /** Append [text] (blank is a store-level no-op) and republish. */ + public suspend fun add(text: String) { + _chips.value = store.add(text) + } + + /** Replace the text of [id] and republish (unknown id / blank text → no-op). */ + public suspend fun edit(id: String, text: String) { + _chips.value = store.edit(id, text) + } + + /** Remove [id] and republish (unknown id → no-op). */ + public suspend fun remove(id: String) { + _chips.value = store.remove(id) + } + + /** Reorder [from]→[to] and republish (out-of-range → no-op). */ + public suspend fun move(from: Int, to: Int) { + _chips.value = store.move(from, to) + } +} + +// ── The editor's select / commit reducer (pure, JVM-tested) ───────────────────── + +/** + * The editor's whole mutable state: ONE text field that either appends a new chip + * ([selectedId] null) or rewrites the selected one. Immutable — every transition returns a + * fresh snapshot. + * + * A single field (rather than an inline field per row) is deliberate: a per-row bound field + * would write to the store on every keystroke, i.e. one DataStore round-trip per character. + */ +internal data class QuickReplyEditorState( + /** The chip being rewritten, or null while composing a new one. */ + val selectedId: String? = null, + /** The field's current text, carried VERBATIM into the commit (whitespace preserved). */ + val draft: String = "", +) + +/** What committing the current [QuickReplyEditorState] does. */ +internal enum class QuickReplyCommit { ADD, EDIT, NONE } + +/** Load [chip] into the field for rewriting (its id becomes the commit target). */ +internal fun QuickReplyEditorState.selecting(chip: QuickReplyChip): QuickReplyEditorState = + copy(selectedId = chip.id, draft = chip.text) + +/** Back to "composing a new chip" with an empty field. */ +internal fun QuickReplyEditorState.cleared(): QuickReplyEditorState = + QuickReplyEditorState() + +/** + * A blank draft commits NOTHING (mirrors the store's blank guard, so the button can be + * disabled instead of silently no-op-ing); otherwise ADD when nothing is selected, EDIT when + * a chip is. + */ +internal fun QuickReplyEditorState.commitKind(): QuickReplyCommit = when { + draft.isBlank() -> QuickReplyCommit.NONE + selectedId == null -> QuickReplyCommit.ADD + else -> QuickReplyCommit.EDIT +} + +// ── The palette editor ────────────────────────────────────────────────────────── + +/** + * The editable-palette surface (plan §1 "Quick-reply chips + editable palette"): an + * [AlertDialog] over the store's CRUD. One field composes/rewrites; each row can be selected + * (tap 编辑), reordered (▲/▼) or deleted (✕). + * + * The chip texts are the user's OWN strings — never server data — but they are still rendered + * as plain inert [Text] (no Markdown/autolink), consistent with §8. + * + * Layout/scroll behaviour is device-QA (§7); the select/commit decision is the JVM-tested + * [QuickReplyEditorState] reducer. + * + * @param chips the current palette snapshot (from [QuickReplyPalette.chips]). + * @param onAdd / [onEdit] / [onRemove] / [onMove] the store-backed CRUD callbacks. + * @param onDismiss close the editor. + */ +@Composable +public fun QuickReplyPaletteEditor( + chips: List, + onAdd: (String) -> Unit, + onEdit: (id: String, text: String) -> Unit, + onRemove: (id: String) -> Unit, + onMove: (from: Int, to: Int) -> Unit, + onDismiss: () -> Unit, +) { + var state by remember { mutableStateOf(QuickReplyEditorState()) } + // A chip removed while selected must not leave a dangling edit target pointing at a gone id. + // DERIVED, not written back during composition (a composition-time state write is a recomposition + // hazard): the field simply falls back to "adding" and keeps whatever was typed. + val effective = if (state.selectedId != null && chips.none { it.id == state.selectedId }) { + state.copy(selectedId = null) + } else { + state + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("快捷回复") }, + text = { + Column( + modifier = Modifier.heightIn(max = EDITOR_MAX_HEIGHT_DP.dp), + verticalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + QuickReplyEditorField( + state = effective, + onDraftChange = { state = effective.copy(draft = it) }, + onCommit = { + when (effective.commitKind()) { + QuickReplyCommit.ADD -> onAdd(effective.draft) + QuickReplyCommit.EDIT -> onEdit(requireNotNull(effective.selectedId), effective.draft) + QuickReplyCommit.NONE -> Unit + } + state = effective.cleared() + }, + onCancelSelection = { state = effective.cleared() }, + ) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Spacing.xs4), + ) { + if (chips.isEmpty()) { + Text("还没有快捷回复。", style = MaterialTheme.typography.bodySmall) + } + chips.forEachIndexed { index, chip -> + QuickReplyEditorRow( + chip = chip, + isSelected = chip.id == effective.selectedId, + canMoveUp = index > 0, + canMoveDown = index < chips.lastIndex, + onSelect = { state = effective.selecting(chip) }, + onMoveUp = { onMove(index, index - 1) }, + onMoveDown = { onMove(index, index + 1) }, + onRemove = { onRemove(chip.id) }, + ) + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("完成") } }, + ) +} + +/** The single compose/rewrite field + its commit action. */ +@Composable +private fun QuickReplyEditorField( + state: QuickReplyEditorState, + onDraftChange: (String) -> Unit, + onCommit: () -> Unit, + onCancelSelection: () -> Unit, +) { + OutlinedTextField( + value = state.draft, + onValueChange = onDraftChange, + label = { Text(if (state.selectedId == null) "新增内容" else "修改内容") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + TextButton( + onClick = onCommit, + enabled = state.commitKind() != QuickReplyCommit.NONE, + ) { Text(if (state.selectedId == null) "添加" else "保存") } + if (state.selectedId != null) { + TextButton(onClick = onCancelSelection) { Text("取消") } + } + } +} + +/** One palette row: the (inert) text plus select / reorder / delete affordances. */ +@Composable +private fun QuickReplyEditorRow( + chip: QuickReplyChip, + isSelected: Boolean, + canMoveUp: Boolean, + canMoveDown: Boolean, + onSelect: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onRemove: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.xs4), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = chip.text, + style = MaterialTheme.typography.bodyMedium, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onSelect) { Text("编辑") } + TextButton(onClick = onMoveUp, enabled = canMoveUp) { Text("▲") } + TextButton(onClick = onMoveDown, enabled = canMoveDown) { Text("▼") } + TextButton(onClick = onRemove) { Text("✕") } + } +} + +/** Cap on the editor body height so a long palette scrolls inside the dialog. */ +private const val EDITOR_MAX_HEIGHT_DP: Int = 320 + // ── Preview ───────────────────────────────────────────────────────────────────── @Preview(name = "QuickReply (gate held)") @@ -109,3 +359,18 @@ private fun QuickReplyPreview() { ) } } + +@Preview(name = "QuickReply palette editor") +@Composable +private fun QuickReplyPaletteEditorPreview() { + WebTermTheme { + QuickReplyPaletteEditor( + chips = QuickReplyDefaults.chips, + onAdd = {}, + onEdit = { _, _ -> }, + onRemove = {}, + onMove = { _, _ -> }, + onDismiss = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/AuthCookieModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/AuthCookieModule.kt new file mode 100644 index 0000000..b1545d6 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/AuthCookieModule.kt @@ -0,0 +1,136 @@ +package wang.yaojia.webterm.di + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import okhttp3.CookieJar +import wang.yaojia.webterm.hostregistry.AuthCookieCipher +import wang.yaojia.webterm.hostregistry.AuthCookieStore +import wang.yaojia.webterm.hostregistry.DataStoreAuthCookieStore +import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore +import wang.yaojia.webterm.tlsandroid.TinkAuthCookieCipher +import wang.yaojia.webterm.transport.AuthCookieJar +import wang.yaojia.webterm.wiring.toRecord +import javax.inject.Singleton + +/** + * The optional `WEBTERM_TOKEN` gate's client boundary (server `src/http/auth.ts`). + * + * Three things are wired here, and all three are required for the gate to work at all: + * 1. **[AuthCookieJar] on the ONE shared `OkHttpClient`** (via the [CookieJar] binding [NetworkModule] + * consumes). Both transports share that client, so the `webterm_auth` cookie rides every REST call + * AND the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls too). Without this the + * client runs on `CookieJar.NO_COOKIES` and a token-gated host 401s everything. + * 2. **[AuthCookieStore]** so the credential survives a process restart — encrypted at rest. + * 3. **The [AuthCookieCipher] seam** bridging `:host-registry` (which must not depend on Tink) to + * `:client-tls-android`'s [TinkAuthCookieCipher] (which must not depend on `:host-registry`) — the + * same sibling-bridge pattern [NetworkModule] uses for the mTLS `ClientIdentityProvider`. + * + * ### Fail CLOSED + * [DataStoreAuthCookieStore] takes the cipher as a REQUIRED constructor argument precisely so no wiring + * can persist plaintext by omission. If a real cipher cannot be constructed, this module binds + * [InMemoryAuthCookieStore] instead: memory-only persistence (the user re-authenticates after a restart) + * is the correct answer, and writing a full-shell credential in the clear is not an option. A cipher that + * constructs but later fails to seal is handled the same way one level down — the store persists NOTHING + * and drops any previous blob. + * + * ### Threading + * The graph builds nothing expensive: [TinkAuthCookieCipher] defers all AEAD/keystore work to first use, + * and `AppEnvironment.warmUp()` does the first read off `Main`. The persister side must be non-blocking + * because OkHttp calls a `CookieJar` synchronously on the call thread, so the durable write is handed to + * an app-scoped IO scope. + */ +@Module +@InstallIn(SingletonComponent::class) +public object AuthCookieModule { + + /** + * The durable home of the `webterm_auth` cookie. Shares the ONE Preferences DataStore with the host + * list / last-session pointer ([StorageModule]) — disjoint keys (`authCookiesSealed` vs `hosts` / + * `lastSessionId.`) — but the VALUE here is Tink-AEAD ciphertext, never plaintext. + */ + @Provides + @Singleton + public fun provideAuthCookieStore( + dataStore: DataStore, + @ApplicationContext context: Context, + ): AuthCookieStore { + val cipher = buildAuthCookieCipher(context) + if (cipher == null) { + // Fail closed: memory-only rather than a plaintext credential at rest. + Log.w(TAG, "auth-cookie cipher unavailable — the session cookie will not be persisted") + return InMemoryAuthCookieStore() + } + return DataStoreAuthCookieStore( + dataStore = dataStore, + cipher = cipher, + onCipherFailure = ::reportCipherFailure, + ) + } + + /** + * The ONE jar. Its persister hands each host's changed cookie set to [AuthCookieStore] on an + * app-scoped IO scope — never blocking OkHttp's call thread on DataStore/Tink I/O. The scope lives for + * the process (there is no later moment at which a pending credential write should be abandoned). + */ + @Provides + @Singleton + public fun provideAuthCookieJar(store: AuthCookieStore): AuthCookieJar { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + return AuthCookieJar( + persister = { hostKey, cookies -> + scope.launch { + runCatching { store.replaceHost(hostKey, cookies.map { it.toRecord(hostKey) }) } + .onFailure(::reportPersistFailure) + } + }, + ) + } + + /** What [NetworkModule] installs on the shared client — the same instance as [provideAuthCookieJar]. */ + @Provides + @Singleton + public fun provideCookieJar(jar: AuthCookieJar): CookieJar = jar + + /** + * The 4-line adapter [TinkAuthCookieCipher]'s KDoc prescribes: it matches [AuthCookieCipher]'s shape + * exactly but cannot declare it (sibling modules), so `:app` bridges the two. + * + * Returns null if the cipher cannot even be CONSTRUCTED. Today's implementation defers all keystore + * work to first use and so does not throw here; the guard exists because the alternative to "no + * cipher" must always be "no persistence", never "plaintext persistence". + */ + private fun buildAuthCookieCipher(context: Context): AuthCookieCipher? = + runCatching { + val tink = TinkAuthCookieCipher(context) + object : AuthCookieCipher { + override fun seal(plaintext: String): String = tink.seal(plaintext) + override fun open(sealed: String): String = tink.open(sealed) + } + }.getOrNull() + + /** + * A seal/open failure means a credential was dropped instead of stored (or a stored one could not be + * read). Reported by TYPE only: neither the plaintext nor the ciphertext may reach a log. + */ + private fun reportCipherFailure(error: Throwable) { + Log.w(TAG, "auth-cookie at-rest crypto failed (${error::class.simpleName}) — nothing was persisted") + } + + /** A durable-write failure leaves the cookie in memory only; same logging discipline. */ + private fun reportPersistFailure(error: Throwable) { + Log.w(TAG, "auth-cookie persist failed (${error::class.simpleName}) — cookie kept in memory only") + } + + private const val TAG: String = "WebTermAuthCookie" +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt index ab0165e..ea4cbd6 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt @@ -5,6 +5,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.ConnectionPool +import okhttp3.CookieJar import okhttp3.OkHttpClient import wang.yaojia.webterm.transport.ClientIdentity import wang.yaojia.webterm.transport.ClientIdentityProvider @@ -27,6 +28,11 @@ import javax.inject.Singleton * re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no * client rebuild (R4). The provider is read ONCE, when the client is built. * + * ### The WEBTERM_TOKEN cookie jar + * The shared client also carries the ONE [CookieJar] provided by [AuthCookieModule], so the optional + * access-token gate applies uniformly to REST and to the WS upgrade. See that module for the at-rest + * (Tink AEAD) custody of the cookie. + * * ### Breaking the construction cycle (A11's frozen constructor) * `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the * client needs the repository's SSL material — a cycle. It is broken with an explicit shared @@ -53,14 +59,24 @@ public object NetworkModule { ClientIdentity(material.sslSocketFactory, material.trustManager) } - /** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */ + /** + * The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool] and + * carrying the ONE [CookieJar] ([AuthCookieModule]). + * + * The cookie jar is what makes the optional `WEBTERM_TOKEN` gate work: a gated host answers pairing + * with `Set-Cookie: webterm_auth=…` and then requires that cookie on every remote route AND on the WS + * upgrade. Because both transports share this client, installing the jar HERE covers both at once + * (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Leaving it at `CookieJar.NO_COOKIES` — as + * this provider did before — makes the whole token feature inert. + */ @Provides @Singleton public fun provideOkHttpClient( identityProvider: ClientIdentityProvider, connectionPool: ConnectionPool, + cookieJar: CookieJar, ): OkHttpClient = - OkHttpClientFactory.create(identityProvider) + OkHttpClientFactory.create(identityProvider, cookieJar) .newBuilder() .connectionPool(connectionPool) .build() diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/ThumbnailModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/ThumbnailModule.kt new file mode 100644 index 0000000..f48f6a5 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/ThumbnailModule.kt @@ -0,0 +1,31 @@ +package wang.yaojia.webterm.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import wang.yaojia.webterm.wiring.CanvasThumbnailRasterizer +import wang.yaojia.webterm.wiring.ThumbnailRasterizer +import javax.inject.Singleton + +/** + * Session-preview boundary (plan §6.7): binds the off-screen rasterizer the thumbnail pipeline paints + * with, so `AppEnvironment.buildThumbnailPipeline(endpoint)` can mint a per-host pipeline and the session + * list finally renders real previews instead of placeholder tiles. + * + * ONE rasterizer for the whole app: its cell metrics are measured once, and it is explicitly safe to + * share across the pipeline's concurrent renders (each `rasterize` copies the metrics `Paint` into a + * local draw `Paint` — see [CanvasThumbnailRasterizer]). + * + * Consumers inject it as `dagger.Lazy` (`AppEnvironment`): constructing it measures a `Paint`, which is an + * `android.graphics` touch that must not happen while the Hilt graph is assembled (android.graphics is + * stubbed under JVM unit tests, and graph assembly happens on `Main`). + */ +@Module +@InstallIn(SingletonComponent::class) +public object ThumbnailModule { + + @Provides + @Singleton + public fun provideThumbnailRasterizer(): ThumbnailRasterizer = CanvasThumbnailRasterizer() +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt index 3899926..ba4b69e 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -18,12 +20,16 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import wang.yaojia.webterm.components.ContinueLastBanner +import wang.yaojia.webterm.components.SessionThumbnails import wang.yaojia.webterm.components.continueLastModel +import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.screens.AdaptiveHome import wang.yaojia.webterm.screens.SessionListScreen import wang.yaojia.webterm.viewmodels.ApiClientSessionGateway import wang.yaojia.webterm.viewmodels.SessionListViewModel import wang.yaojia.webterm.wiring.AppEnvironment +import wang.yaojia.webterm.wiring.ThumbnailPipeline +import wang.yaojia.webterm.wiring.sessionThumbnailPipeline import java.util.UUID /** @@ -39,6 +45,21 @@ import java.util.UUID * * The [SessionListViewModel] host menu drives its own active-host selection; this pane reads the resolved * active host id from its state to build the terminal endpoint / continue-last pointer. + * + * ### Live preview thumbnails (plan §6.7) + * This is the ONE production construction point of the [ThumbnailPipeline]: the active host's endpoint is + * resolved to an [ApiClient][wang.yaojia.webterm.api.routes.ApiClient] and handed to + * [sessionThumbnailPipeline], whose [SessionThumbnails] adapter is threaded into [SessionListScreen] so + * every row shows the session's actual screen instead of a placeholder tile. The fetch is the READ-ONLY + * `GET /live-sessions/:id/preview` — it never attaches, so merely looking at the chooser cannot inflate a + * session's client count or reset its idle clock. The pipeline is pruned to the current row set on every + * poll emission (dead sessions release their bitmaps) and closed when this pane leaves the composition. + * + * ### Pointer context menu (A26) + * The resolved [LayoutMode] is passed down so [SessionListScreen] can wrap each row in a + * [PointerContextMenu][wang.yaojia.webterm.components.PointerContextMenu]; the gate itself is + * [PointerMenuPolicy], evaluated inside that wrapper. The menu's「在当前目录开新会话」routes exactly like + * the terminal toolbar's does. */ @Composable public fun SessionsHome( @@ -70,6 +91,38 @@ public fun SessionsHome( val openNewSession: () -> Unit = { activeHostId?.let { navController.navigate(NavRoutes.terminalNew(it)) } } + // A26 pointer-menu「在当前目录开新会话」— same `attach(null, cwd)` route the terminal toolbar uses. + val openNewSessionInCwd: (String) -> Unit = { cwd -> + activeHostId?.let { navController.navigate(newTerminalRoute(it, cwd)) } + } + + // ── §6.7 live preview thumbnails, for the ACTIVE host ──────────────────────────────────────────── + // The VM exposes only host ids, so resolve the endpoint here (the pipeline needs a per-host ApiClient). + val activeHost by produceState(initialValue = null, key1 = activeHostId) { + // Clear FIRST: produceState keeps the previous value across a key change, so without this a host + // switch would leave the OLD host's endpoint in place and the pipeline would fetch host A's + // session ids against host B until the resolve lands. + value = null + value = activeHostId?.let { id -> + runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == id } }.getOrNull() + } + } + // Constructing the pipeline is cheap and Main-safe: the ApiClient (→ shared OkHttpClient → mTLS + // material) resolves lazily on the first fetch, which runs on the pipeline's own dispatcher. + val pipeline: ThumbnailPipeline? = remember(activeHost) { + activeHost?.let { host -> sessionThumbnailPipeline { env.apiClientFactory.create(host.endpoint) } } + } + DisposableEffect(pipeline) { + onDispose { pipeline?.close() } + } + val thumbnails: SessionThumbnails? = remember(pipeline) { + pipeline?.let { live -> SessionThumbnails { session -> live.thumbnail(session) } } + } + // Release bitmaps for sessions that are no longer live (killed/exited) and superseded lastOutputAt + // keys — a thumbnail must never outlive the session it depicts. + LaunchedEffect(pipeline, state.rows) { + pipeline?.retainOnly(state.rows.map { it.info }) + } AdaptiveHome( listPane = { @@ -89,6 +142,9 @@ public fun SessionsHome( onPairHost = { navController.navigate(NavRoutes.PAIRING) }, onEnroll = { navController.navigate(NavRoutes.ENROLL) }, onImportCert = { navController.navigate(NavRoutes.CERT) }, + thumbnails = thumbnails, + layoutMode = mode, + onNewSessionInCwd = openNewSessionInCwd, ) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt index 2eb3faa..32069cd 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -38,6 +39,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat @@ -50,8 +53,10 @@ import com.google.mlkit.vision.common.InputImage import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.viewmodels.EntryError +import wang.yaojia.webterm.viewmodels.PairingCopy import wang.yaojia.webterm.viewmodels.PairingUiState import wang.yaojia.webterm.viewmodels.PairingViewModel +import wang.yaojia.webterm.viewmodels.TokenError import wang.yaojia.webterm.viewmodels.WarningTier import wang.yaojia.webterm.viewmodels.needsExplicitAck @@ -71,10 +76,14 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck * - **POST_NOTIFICATIONS** is NOT requested here — push registration owns that prompt (A31); pairing * only establishes the host. * - * All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is - * JVM-tested in `PairingViewModelTest`. + * A host behind the optional `WEBTERM_TOKEN` gate cannot answer the probe at all until it is + * authenticated, so the flow has one more step: the masked access-token prompt (see [TokenRequiredStep]). * - * @param viewModel the pairing state machine (nav layer builds it with the real [pairingProber]). + * All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is + * JVM-tested in `PairingViewModelTest` + `PairingTokenFlowTest`. + * + * @param viewModel the pairing state machine (the nav layer builds it with the real prober from + * `AppEnvironment.buildPairingProber()`). * @param onPaired invoked once with the persisted [Host] after a successful pair. * @param onImportCert opens the device-cert screen (A27) when a tunnel host is cert-gated. */ @@ -114,6 +123,13 @@ public fun PairingScreen( onRetry = { viewModel.retry() }, onCancel = viewModel::reset, ) + is PairingUiState.TokenRequired -> TokenRequiredStep( + host = s.name, + error = s.error, + onSubmit = viewModel::submitAccessToken, + onCancel = viewModel::reset, + ) + is PairingUiState.TokenSubmitting -> BusyStep(message = "正在验证访问令牌 …") is PairingUiState.Failed -> FailedStep( message = s.message, needsAck = s.tier.needsExplicitAck, @@ -295,14 +311,82 @@ private fun tierCopy(tier: WarningTier): Pair = when (tier) { @Composable private fun ProbingStep(host: String) { + BusyStep(message = "正在验证 $host …") +} + +@Composable +private fun BusyStep(message: String) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) { CircularProgressIndicator() - Text("正在验证 $host …", style = MaterialTheme.typography.bodyMedium) + Text(message, style = MaterialTheme.typography.bodyMedium) } } } +// ── Access token (the optional WEBTERM_TOKEN gate) ─────────────────────────────────────────────────── + +/** + * The access-token prompt for a `WEBTERM_TOKEN`-gated host. + * + * The token is a **shell credential**, so the field is masked ([PasswordVisualTransformation]) and typed + * as [KeyboardType.Password] (which also keeps it out of the keyboard's suggestion/learning store), and + * the composition drops it the instant it is handed to the ViewModel — nothing here, and nothing in the + * resulting UI state, retains it. All copy is app-authored and inert (plan §8). + */ +@Composable +private fun TokenRequiredStep( + host: String, + error: TokenError?, + onSubmit: (String) -> Unit, + onCancel: () -> Unit, +) { + var token by remember(host) { mutableStateOf("") } + + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(Spacing.md12), + ) { + Column(modifier = Modifier.padding(Spacing.md12), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) { + Text("需要访问令牌", style = MaterialTheme.typography.titleSmall) + Text( + text = "$host 启用了访问令牌(WEBTERM_TOKEN)。输入令牌后即可继续配对;令牌只用于本次验证,不会显示或记录。", + style = MaterialTheme.typography.bodySmall, + ) + } + } + OutlinedTextField( + value = token, + onValueChange = { token = it }, + label = { Text("访问令牌") }, + singleLine = true, + isError = error != null, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + if (error != null) { + Text( + text = PairingCopy.describeToken(error), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") } + Button( + onClick = { + // Hand it over and forget it in the same breath — never leave the credential in state. + val submitted = token + token = "" + onSubmit(submitted) + }, + enabled = token.isNotBlank(), + modifier = Modifier.weight(1f), + ) { Text("提交") } + } +} + @Composable private fun CertRequiredStep( host: String, diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt index 094abc2..53ac19c 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt @@ -37,10 +37,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import wang.yaojia.webterm.api.models.CommitLogEntry +import wang.yaojia.webterm.api.models.GitLogResult import wang.yaojia.webterm.api.models.PrAvailability import wang.yaojia.webterm.api.models.PrStatus import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectSessionRef +import wang.yaojia.webterm.api.models.SyncState import wang.yaojia.webterm.api.models.WorktreeInfo import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.Stroke @@ -48,8 +50,18 @@ import wang.yaojia.webterm.designsystem.WebTermCard import wang.yaojia.webterm.designsystem.WebTermColors import wang.yaojia.webterm.designsystem.WebTermTheme import wang.yaojia.webterm.designsystem.WebTermType +import wang.yaojia.webterm.viewmodels.GitChipTone +import wang.yaojia.webterm.viewmodels.GitPanelCopy import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel +import wang.yaojia.webterm.viewmodels.SyncBand +import wang.yaojia.webterm.viewmodels.boundaryIndex +import wang.yaojia.webterm.viewmodels.boundaryLabel +import wang.yaojia.webterm.viewmodels.isUnpushed +import wang.yaojia.webterm.viewmodels.WorktreeRowState import wang.yaojia.webterm.viewmodels.WorktreeViewModel +import wang.yaojia.webterm.viewmodels.headerDirtyChip +import wang.yaojia.webterm.viewmodels.syncBandOf +import wang.yaojia.webterm.viewmodels.worktreeChips import java.net.URI /** @@ -74,8 +86,15 @@ public fun ProjectDetailScreen( val phase by viewModel.phase.collectAsStateWithLifecycle() val prChip by viewModel.prChip.collectAsStateWithLifecycle() val recent by viewModel.recentCommits.collectAsStateWithLifecycle() + val fetch by viewModel.fetchPhase.collectAsStateWithLifecycle() + val worktreeStates by viewModel.worktreeStates.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() - LaunchedEffect(viewModel) { viewModel.load() } + LaunchedEffect(viewModel) { + viewModel.load() + // w6/G7: probe the other worktrees once per opened project (never per tick) — each row's + // failure is isolated, so a bad probe costs one chip, not the panel. + viewModel.probeWorktrees() + } Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column(modifier = Modifier.fillMaxSize()) { @@ -91,7 +110,12 @@ public fun ProjectDetailScreen( detail = current.detail, prChip = prChip, recent = recent, + fetch = fetch, + canFetch = viewModel.canFetch, + onFetch = { scope.launch { viewModel.fetchUpstream() } }, + onDismissFetchBanner = viewModel::clearFetchBanner, worktree = viewModel.worktree, + worktreeStates = worktreeStates, onOpenClaude = onOpenClaude, onViewDiff = onViewDiff, ) @@ -120,6 +144,11 @@ private fun DetailBody( worktree: WorktreeViewModel?, onOpenClaude: (String) -> Unit, onViewDiff: (String) -> Unit = {}, + fetch: ProjectDetailViewModel.FetchPhase = ProjectDetailViewModel.FetchPhase.Idle, + canFetch: Boolean = false, + onFetch: () -> Unit = {}, + onDismissFetchBanner: () -> Unit = {}, + worktreeStates: Map = emptyMap(), ) { Column( modifier = Modifier @@ -139,19 +168,34 @@ private fun DetailBody( modifier = Modifier.weight(1f), ) detail.branch?.let { Text(text = it, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) } - if (detail.dirty == true) Text(text = "●", color = WebTermColors.statusWaiting) + // w6/G3: a count beats a bare dot — "how many uncommitted" is the question the dot dodged. + headerDirtyChip(detail.dirtyCount, detail.dirty)?.let { + Text(text = it, style = WebTermType.metaMono, color = WebTermColors.statusWaiting) + } } Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) + SyncBandCard( + // On a detached HEAD there is no branch to show, so the short sha stands in for it; the + // current worktree row is where the server reports it. + band = syncBandOf(detail.sync, head = detail.worktrees.firstOrNull { it.isCurrent }?.head), + fetch = fetch, + canFetch = canFetch, + onFetch = onFetch, + onDismissFetchBanner = onDismissFetchBanner, + ) + PrChipRow(prChip) if (detail.isGit && worktree != null) { - WorktreeSection(detail = detail, worktree = worktree) + WorktreeSection(detail = detail, worktree = worktree, worktreeStates = worktreeStates) } else { - SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支") + SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size)) if (!detail.isGit) EmptyLine("不是 git 仓库。") else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。") - else for (w in detail.worktrees) WorktreeRow(w, onRemove = null) + else for (w in detail.worktrees) { + WorktreeRow(w, onRemove = null, state = worktreeStateOf(w, detail, worktreeStates)) + } } val running = detail.sessions.filter { !it.exited } @@ -173,6 +217,134 @@ private fun DetailBody( } } +// ── w6/G3: the sync band ───────────────────────────────────────────────────────────────────────── + +/** + * The header sync band: upstream · ↑ahead · ↓behind · Fetch. Stacked (not a 4-column row) because a + * phone is narrow and the mock's desktop row collapses to a column at this width anyway. + * + * **The one design rule:** only [SyncBand.isAllClear] paints green. A stale `↓0` carries the + * "存疑" chip on the NUMBER (↑ never does — it needs only local refs), and a branch with no upstream + * says so in words. Absent facts render nothing at all. + */ +@Composable +private fun SyncBandCard( + band: SyncBand?, + fetch: ProjectDetailViewModel.FetchPhase, + canFetch: Boolean, + onFetch: () -> Unit, + onDismissFetchBanner: () -> Unit, +) { + if (band == null) return + WebTermCard(modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) { + when (band) { + is SyncBand.Detached -> SyncCell(GitPanelCopy.HEAD_CAPTION) { + Text( + text = band.head?.let { "${GitPanelCopy.DETACHED_CHIP} @ $it" } ?: GitPanelCopy.DETACHED_CHIP, + style = WebTermType.metaMono, + color = WebTermColors.statusStuck, + ) + } + + SyncBand.NoUpstream -> { + SyncCell(GitPanelCopy.UPSTREAM_CAPTION) { + Text(text = GitPanelCopy.NO_UPSTREAM_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusStuck) + } + EmptyLine(GitPanelCopy.NO_UPSTREAM_NOTE) + } + + is SyncBand.Tracking -> TrackingCells(band) + } + FetchRow( + band = band, + fetch = fetch, + canFetch = canFetch, + onFetch = onFetch, + onDismissFetchBanner = onDismissFetchBanner, + ) + } + } +} + +@Composable +private fun TrackingCells(band: SyncBand.Tracking) { + SyncCell(GitPanelCopy.UPSTREAM_CAPTION) { + // Upstream is a server string (a remote-tracking ref name) → inert text. + Text(text = band.upstream, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + SyncCell(GitPanelCopy.TO_PUSH_CAPTION) { + Text( + text = GitPanelCopy.aheadChip(band.ahead), + style = WebTermType.metaMono, + color = if (band.ahead > 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (band.isAllClear) { + Text(text = GitPanelCopy.IN_SYNC_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusWorking) + } + } + EmptyLine(if (band.ahead == 0) GitPanelCopy.NOTHING_TO_PUSH_NOTE else GitPanelCopy.aheadNote(band.ahead)) + SyncCell(GitPanelCopy.TO_PULL_CAPTION) { + Text( + text = GitPanelCopy.behindChip(band.behind), + style = WebTermType.metaMono, + // Behind is read off a CACHED remote ref: dim the digit itself when it cannot be trusted. + color = if (band.isStale) WebTermColors.statusStuck else MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (band.isStale) { + Text(text = GitPanelCopy.STALE_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusStuck) + } + } + EmptyLine( + when { + band.isStale && band.lastFetchMs == null -> GitPanelCopy.NEVER_FETCHED_NOTE + band.isStale -> GitPanelCopy.STALE_NOTE + band.behind == 0 -> GitPanelCopy.UP_TO_DATE_NOTE + else -> GitPanelCopy.WAITING_ON_REMOTE_NOTE + }, + ) +} + +@Composable +private fun SyncCell(caption: String, value: @Composable () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + Text( + text = caption, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + value() + } +} + +@Composable +private fun FetchRow( + band: SyncBand, + fetch: ProjectDetailViewModel.FetchPhase, + canFetch: Boolean, + onFetch: () -> Unit, + onDismissFetchBanner: () -> Unit, +) { + val working = fetch == ProjectDetailViewModel.FetchPhase.Working + // Fetch only moves remote-tracking refs — never a pull, never a merge. Disabled on a detached + // HEAD (nothing to compare) and while one is in flight (a second tap is dropped, not queued). + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + OutlinedButton(enabled = canFetch && band.canFetch && !working, onClick = onFetch) { + Text(GitPanelCopy.FETCH) + } + when (fetch) { + ProjectDetailViewModel.FetchPhase.Idle -> if (!band.canFetch) { + EmptyLine(GitPanelCopy.FETCH_DISABLED_DETACHED) + } + ProjectDetailViewModel.FetchPhase.Working -> EmptyLine(GitPanelCopy.FETCH_WORKING) + is ProjectDetailViewModel.FetchPhase.Done -> + BannerLine(GitPanelCopy.FETCH_DONE, WebTermColors.statusWorking, onDismissFetchBanner) + is ProjectDetailViewModel.FetchPhase.Failed -> + BannerLine(fetch.message, WebTermColors.statusStuck, onDismissFetchBanner) + } + } +} + // ── PR + CI chip (link only when https) ────────────────────────────────────────────────────────── @Composable @@ -230,18 +402,30 @@ private fun isHttpsUrl(url: String): Boolean = // ── Worktree section (create / remove / prune) ──────────────────────────────────────────────────── @Composable -private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) { +private fun WorktreeSection( + detail: ProjectDetail, + worktree: WorktreeViewModel, + worktreeStates: Map, +) { val scope = rememberCoroutineScope() val phase by worktree.phase.collectAsStateWithLifecycle() var branch by remember { mutableStateOf("") } var base by remember { mutableStateOf("") } var removeTarget by remember { mutableStateOf(null) } - SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支") + // w6/G5: ALWAYS "Worktrees (n)" — one worktree per session makes the count the point, and a + // section that renames itself to "Branch" at n=1 hides the whole model. + SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size)) if (detail.worktrees.isEmpty()) { EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。") } else { - for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w }) + for (w in detail.worktrees) { + WorktreeRow( + worktree = w, + state = worktreeStateOf(w, detail, worktreeStates), + onRemove = { if (!w.isMain) removeTarget = w }, + ) + } } // New-worktree inline form. @@ -337,31 +521,77 @@ private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) { when (recent) { ProjectDetailViewModel.RecentCommits.Hidden -> Unit ProjectDetailViewModel.RecentCommits.Loading -> { - SectionTitle("最近提交"); EmptyLine("正在读取提交记录…") + SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("正在读取提交记录…") } ProjectDetailViewModel.RecentCommits.Unavailable -> { - SectionTitle("最近提交"); EmptyLine("提交记录不可用。") + SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("提交记录不可用。") } - is ProjectDetailViewModel.RecentCommits.Loaded -> { - SectionTitle("最近提交") - if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。") - else for (commit in recent.result.commits) CommitRow(commit) + is ProjectDetailViewModel.RecentCommits.Loaded -> CommitList(recent.result) + } +} + +/** + * The commit list with w6/G4's unpushed marking: a marked row carries `↑` and the accent, and the + * upstream boundary is drawn EXACTLY ONCE, right after the last marked row — it is the real position + * of `origin/` on this timeline, so it is only drawn when there is an upstream to name. + */ +@Composable +private fun CommitList(log: GitLogResult) { + val boundaryLabel = log.boundaryLabel() + val boundaryIndex = log.boundaryIndex() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + SectionTitle(GitPanelCopy.RECENT_COMMITS) + // The count belongs ON the heading: it qualifies "recent commits", it is not its own statement. + if (log.unpushedCount > 0 && boundaryLabel != null) { + Text( + text = GitPanelCopy.unpushedBadge(log.unpushedCount), + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.primary, + ) } } + if (log.commits.isEmpty()) { + EmptyLine("暂无提交。") + return + } + log.commits.forEachIndexed { index, commit -> + CommitRow(commit) + if (index == boundaryIndex && boundaryLabel != null) UpstreamBoundary(boundaryLabel) + } + if (log.truncated) EmptyLine(GitPanelCopy.truncatedNote(log.commits.size)) +} + +@Composable +private fun UpstreamBoundary(upstream: String) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + // Inert: `upstream` is a server-supplied ref name. + Text(text = upstream, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) + HorizontalDivider( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.outline, + thickness = Stroke.hairline, + ) + } } @Composable private fun CommitRow(commit: CommitLogEntry) { + val unpushed = commit.isUnpushed() Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) { Text( - text = commit.hash.take(7), + text = if (unpushed) GitPanelCopy.UNPUSHED_MARK else " ", + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = commit.hash.take(SHORT_HASH_LENGTH), style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary, ) Text( text = commit.subject, style = WebTermType.metaMono, - color = MaterialTheme.colorScheme.onSurface, + color = if (unpushed) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), @@ -369,25 +599,59 @@ private fun CommitRow(commit: CommitLogEntry) { } } +/** `git log --format=%h` width — the same 7 the rest of the cockpit shows. */ +private const val SHORT_HASH_LENGTH = 7 + // ── Rows / helpers (reused from A23) ──────────────────────────────────────────────────────────────── +/** + * One worktree row: identity + state chips on top, path underneath (on one line the path and the + * chips competed for width and whichever lost got truncated). + * + * The chips come from [worktreeChips], which deliberately has no green "clean" chip: only a fresh + * `↑0 ↓0` earns green, and "no upstream" — the normal state of a fresh worktree branch — is a warning. + */ @Composable -private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) { +private fun WorktreeRow( + worktree: WorktreeInfo, + onRemove: (() -> Unit)?, + state: WorktreeRowState? = null, +) { val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached" WebTermCard(modifier = Modifier.fillMaxWidth()) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) { Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), verticalAlignment = Alignment.CenterVertically) { Text(text = label, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - if (worktree.isMain) Tag("main") - if (worktree.isCurrent) Tag("current") - if (worktree.locked == true) Tag("locked") + for (chip in worktreeChips(worktree, state)) Tag(chip.label, chip.tone) if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") } } Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (state != null && state.sessionCount > 0) { + Text( + text = GitPanelCopy.sessionCount(state.sessionCount), + style = WebTermType.metaMono, + color = WebTermColors.statusWorking, + ) + } } } } +/** + * Per-row git state. The CURRENT worktree's state is the project's own — free, already loaded. Other + * rows need the w6/G7 per-worktree probe (`GET /projects/worktree/state`), which this client does not + * have a route for yet, so they stay unprobed and show no sync chip rather than a guess. + */ +private fun worktreeStateOf( + worktree: WorktreeInfo, + detail: ProjectDetail, + probed: Map, +): WorktreeRowState? { + if (!worktree.isCurrent) return probed[worktree.path] + if (detail.sync == null && detail.dirtyCount == null) return null + return WorktreeRowState(sync = detail.sync, dirtyCount = detail.dirtyCount) +} + @Composable private fun SessionRow(session: ProjectSessionRef) { WebTermCard(modifier = Modifier.fillMaxWidth()) { @@ -413,8 +677,15 @@ private fun ClaudeMdBlock(text: String) { } @Composable -private fun Tag(text: String) { - Text(text = text, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary) +private fun Tag(text: String, tone: GitChipTone = GitChipTone.ACCENT) { + val color = when (tone) { + GitChipTone.NEUTRAL -> MaterialTheme.colorScheme.onSurfaceVariant + GitChipTone.ACCENT -> MaterialTheme.colorScheme.primary + GitChipTone.WARN -> WebTermColors.statusStuck + GitChipTone.DIRTY -> WebTermColors.statusWaiting + GitChipTone.OK -> WebTermColors.statusWorking + } + Text(text = text, style = WebTermType.metaMono, color = color) } @Composable @@ -452,16 +723,27 @@ private fun Centered(content: @Composable () -> Unit) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() } } -@Preview(name = "ProjectDetailScreen — loaded") +@Preview(name = "ProjectDetailScreen — git panel (stale ↓, 9 to push)") @Composable private fun ProjectDetailScreenPreview() { val detail = ProjectDetail( name = "Acme.Web.frontend", path = "/home/dev/acme-web", isGit = true, - branch = "main", + branch = "develop", dirty = true, - worktrees = emptyList(), + dirtyCount = 3, + // The panel's headline case: 9 to push, and a ↓0 that is 19 days old — so NOT green. + sync = SyncState( + upstream = "origin/develop", + ahead = 9, + behind = 0, + lastFetchMs = 1L, + ), + worktrees = listOf( + WorktreeInfo(path = "/home/dev/acme-web", branch = "develop", isMain = true, isCurrent = true), + WorktreeInfo(path = "/home/dev/acme-web/.claude/worktrees/x", branch = "worktree-x"), + ), sessions = emptyList(), hasClaudeMd = true, claudeMd = "# CLAUDE.md\n\nProject instructions…", @@ -471,13 +753,21 @@ private fun ProjectDetailScreenPreview() { detail = detail, prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)), recent = ProjectDetailViewModel.RecentCommits.Loaded( - wang.yaojia.webterm.api.models.GitLogResult( - commits = listOf(CommitLogEntry("abc1234", 1, "Initial commit")), + GitLogResult( + commits = listOf( + CommitLogEntry("553a00c1", 1, "docs(progress): log the leftover fixes", unpushed = true), + CommitLogEntry("1dbed541", 1, "feat(control-panel): web admin UI"), + ), truncated = false, + upstream = "origin/develop", ), ), worktree = null, onOpenClaude = {}, + canFetch = true, + worktreeStates = mapOf( + "/home/dev/acme-web/.claude/worktrees/x" to WorktreeRowState(sync = SyncState(), sessionCount = 1), + ), ) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt index cc9e03c..14ed4ec 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt @@ -43,10 +43,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.launch +import wang.yaojia.webterm.components.PointerContextMenu import wang.yaojia.webterm.components.SessionListRow import wang.yaojia.webterm.components.SessionThumbnails +import wang.yaojia.webterm.components.sessionRowMenuActions import wang.yaojia.webterm.designsystem.Radius import wang.yaojia.webterm.designsystem.Spacing +import wang.yaojia.webterm.nav.LayoutMode import wang.yaojia.webterm.viewmodels.HostOption import wang.yaojia.webterm.viewmodels.SessionListUiState import wang.yaojia.webterm.viewmodels.SessionListViewModel @@ -81,6 +84,12 @@ import java.util.UUID * @param onImportCert host-menu **设备证书** → the device-cert screen (A27). * @param thumbnails off-screen preview seam; production wires it to the active host's * [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles. + * @param layoutMode the adaptive layout from [wang.yaojia.webterm.nav.LayoutPolicy]; it gates the + * per-row pointer context menu (A26) via + * [PointerMenuPolicy][wang.yaojia.webterm.nav.PointerMenuPolicy] inside [PointerContextMenu]. The + * [LayoutMode.STACK] default means a caller that does not pass it gets today's phone behaviour. + * @param onNewSessionInCwd pointer-menu「在当前目录开新会话」→ `attach(null, cwd)` in the row's cwd. + * `null` (the default) drops that entry. */ @Composable public fun SessionListScreen( @@ -92,6 +101,8 @@ public fun SessionListScreen( onImportCert: () -> Unit, modifier: Modifier = Modifier, thumbnails: SessionThumbnails? = null, + layoutMode: LayoutMode = LayoutMode.STACK, + onNewSessionInCwd: ((String) -> Unit)? = null, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() @@ -129,6 +140,8 @@ public fun SessionListScreen( onNewSession = onNewSession, onPairHost = onPairHost, thumbnails = thumbnails, + layoutMode = layoutMode, + onNewSessionInCwd = onNewSessionInCwd, ) } } @@ -208,6 +221,8 @@ private fun SessionListBody( onNewSession: () -> Unit, onPairHost: () -> Unit, thumbnails: SessionThumbnails?, + layoutMode: LayoutMode, + onNewSessionInCwd: ((String) -> Unit)?, ) { androidx.compose.material3.pulltorefresh.PullToRefreshBox( isRefreshing = state.isRefreshing, @@ -223,7 +238,14 @@ private fun SessionListBody( actionLabel = "开新会话", onAction = onNewSession, ) - else -> SessionList(state = state, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails) + else -> SessionList( + state = state, + onOpen = onOpen, + onKill = onKill, + thumbnails = thumbnails, + layoutMode = layoutMode, + onNewSessionInCwd = onNewSessionInCwd, + ) } } } @@ -234,6 +256,8 @@ private fun SessionList( onOpen: (UUID) -> Unit, onKill: (UUID) -> Unit, thumbnails: SessionThumbnails?, + layoutMode: LayoutMode, + onNewSessionInCwd: ((String) -> Unit)?, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -244,7 +268,14 @@ private fun SessionList( item(key = "load-error") { LoadErrorBanner() } } items(items = state.rows, key = { it.id.toString() }) { row -> - SwipeToKillRow(row = row, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails) + SwipeToKillRow( + row = row, + onOpen = onOpen, + onKill = onKill, + thumbnails = thumbnails, + layoutMode = layoutMode, + onNewSessionInCwd = onNewSessionInCwd, + ) } } } @@ -253,6 +284,11 @@ private fun SessionList( * One row wrapped in a trailing (end→start) [SwipeToDismissBox]. Swiping left confirms the kill, which the * ViewModel handles optimistically (the row leaves the list on the next [state] emission). A start→end * swipe is disabled so a left-handed drag can't accidentally kill. + * + * On a large screen the row is ALSO wrapped in [PointerContextMenu] (A26), giving mouse/trackpad users the + * same three actions without a swipe: 打开会话 / 在当前目录开新会话 / 终止会话. The wrapper is a transparent + * pass-through whenever [PointerMenuPolicy][wang.yaojia.webterm.nav.PointerMenuPolicy] says no, so the + * phone path is byte-for-byte unchanged. */ @Composable private fun SwipeToKillRow( @@ -260,6 +296,8 @@ private fun SwipeToKillRow( onOpen: (UUID) -> Unit, onKill: (UUID) -> Unit, thumbnails: SessionThumbnails?, + layoutMode: LayoutMode, + onNewSessionInCwd: ((String) -> Unit)?, ) { val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> @@ -276,7 +314,17 @@ private fun SwipeToKillRow( enableDismissFromStartToEnd = false, backgroundContent = { KillBackground() }, ) { - SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails) + PointerContextMenu( + mode = layoutMode, + actions = sessionRowMenuActions( + row = row, + onOpen = onOpen, + onNewSessionInCwd = onNewSessionInCwd, + onKill = onKill, + ), + ) { + SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails) + } } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt index cef28ca..918b359 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt @@ -3,8 +3,9 @@ package wang.yaojia.webterm.screens import android.app.Activity import android.content.Context import android.content.ContextWrapper -import android.view.View import android.view.WindowManager +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,6 +26,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -39,13 +41,24 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import wang.yaojia.webterm.components.AwayDigestView +import wang.yaojia.webterm.components.BannerModel +import wang.yaojia.webterm.components.DataStoreQuickReplyStore import wang.yaojia.webterm.components.GateBanner import wang.yaojia.webterm.components.HardwareKeyRouter import wang.yaojia.webterm.components.KeyBar import wang.yaojia.webterm.components.PlanGateSheet +import wang.yaojia.webterm.components.QuickReply +import wang.yaojia.webterm.components.QuickReplyPalette +import wang.yaojia.webterm.components.QuickReplyPaletteEditor +import wang.yaojia.webterm.components.QuickReplyStore import wang.yaojia.webterm.components.ReconnectBanner import wang.yaojia.webterm.components.TelemetryChips import wang.yaojia.webterm.designsystem.LocalReduceMotion @@ -80,10 +93,11 @@ public object NewSessionInCwd { * * Hosts the forked Termux emulator ([RemoteTerminalView]) via an [AndroidView] and wires it to the * holder-owned [TerminalSessionControllerImpl]. Structure top→bottom: a toolbar (sanitized title + - * "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) + telemetry chips - * ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with the privacy - * cover), the [KeyBar] pinned above the IME, and — for plan gates — the [PlanGateSheet] - * `ModalBottomSheet` overlay. Every server-derived string is rendered INERT by its component (§8). + * 「快捷回复」editor + "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) + + * telemetry chips ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with + * the privacy cover), the [QuickReply] chip row (A25 — floats only while a gate is held), the [KeyBar] + * pinned above the IME, and — for plan gates — the [PlanGateSheet] `ModalBottomSheet` overlay. Every + * server-derived string is rendered INERT by its component (§8). * * The Compose / AndroidView / lifecycle / privacy-shade / haptic behaviour here is DEVICE-QA (plan §7); * the banner reduction, gate stale-guard and new-session-in-cwd routing are the JVM-tested cores. @@ -106,8 +120,16 @@ public object NewSessionInCwd { * cycle rebuilds a fresh emulator (ring replay), while a rotation re-binds the survivor. * `holder.onStop(isChangingConfigurations)` drives that split. * - `FLAG_SECURE` + a cover Composable on `ON_STOP` keep terminal bytes out of the recents snapshot (§8). - * - Latest-writer-wins resize: `ON_RESUME` and window-focus re-send the remembered dims via - * `notifyForegrounded` so the active device reclaims full-screen (plan §6.4). + * + * ### Latest-writer-wins resize (plan §6.4) — who pushes what + * The grid itself is measured and pushed by `:terminal-view` (`RemoteTerminalHostView.onSizeChanged` → + * `TerminalResizeDriver` → `RemoteTerminalSession.updateSize` → `Resize` on the wire), so this screen does + * NOT compute cols×rows and must not re-assert a size the view just pushed. What the view cannot see is + * "this device became the active one again": `ON_RESUME` and a window-focus GAIN therefore call + * `notifyForegrounded`, which re-sends the engine's remembered dims unconditionally (no dedup) and so wins + * the shared PTY size back from whichever device wrote last. The size-changed outlet is only forwarded + * while the session is NOT connected, where the same call is the connect-now nudge — see + * [TerminalReclaimPolicy] for the decision table (JVM-tested). * * @param holder the nav-scoped, config-surviving session holder (`hiltViewModel()` at the destination). * @param onNewSessionInCwd nav callback that opens a new session for the produced [NewSessionRequest]. @@ -116,6 +138,10 @@ public object NewSessionInCwd { * the current session (default no-op keeps the screen usable standalone). This only WIRES the existing * [AwayDigestView.onExpand][wang.yaojia.webterm.components.AwayDigestView] callback outward; no internal * logic changes. + * @param quickReplyStore the A25 palette store. Defaults to the app-graph one + * ([rememberAppQuickReplyStore] — the SAME `DataStore` singleton every other store uses, so + * the palette really persists); a test/preview passes an + * [InMemoryQuickReplyStore][wang.yaojia.webterm.components.InMemoryQuickReplyStore]. */ @Composable public fun TerminalScreen( @@ -127,6 +153,7 @@ public fun TerminalScreen( modifier: Modifier = Modifier, onWarmUp: suspend () -> Unit = {}, onDigestExpand: () -> Unit = {}, + quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(), ) { val context = LocalContext.current val activity = remember(context) { context.findActivity() } @@ -192,13 +219,15 @@ public fun TerminalScreen( val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, activity, controller) { val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_STOP -> { + when { + event == Lifecycle.Event.ON_STOP -> { covered = true holder.onStop(activity?.isChangingConfigurations == true) } - Lifecycle.Event.ON_START -> covered = false - Lifecycle.Event.ON_RESUME -> controller.notifyForegrounded(null, null) + event == Lifecycle.Event.ON_START -> covered = false + // §6.4 reclaim: the engine re-sends its remembered dims with NO dedup, so the returning + // device takes the shared PTY size back even though nothing about it changed. + TerminalReclaimPolicy.reclaimsOnLifecycle(event) -> controller.notifyForegrounded(null, null) else -> Unit } } @@ -210,10 +239,17 @@ public fun TerminalScreen( val windowInfo = LocalWindowInfo.current LaunchedEffect(windowInfo, controller) { snapshotFlow { windowInfo.isWindowFocused }.collect { focused -> - if (focused) controller.notifyForegrounded(null, null) + if (TerminalReclaimPolicy.reclaimsOnWindowFocus(focused)) controller.notifyForegrounded(null, null) } } + // A25 quick-reply palette: one remembered presenter over the injected store, loaded once per store. + val palette = remember(quickReplyStore) { QuickReplyPalette(quickReplyStore) } + val quickReplyChips by palette.chips.collectAsStateWithLifecycle() + val paletteScope = rememberCoroutineScope() + var showPaletteEditor by remember { mutableStateOf(false) } + LaunchedEffect(palette) { palette.load() } + val requestNewSession: () -> Unit = { onNewSessionInCwd(NewSessionInCwd.requestFor(cwd)) } // A slow wall-clock tick so the telemetry chips grey out as the last frame ages (§ staleness). @@ -225,7 +261,11 @@ public fun TerminalScreen( } Column(modifier = modifier.fillMaxSize()) { - TerminalToolbar(title = title, onNewSessionInCwd = requestNewSession) + TerminalToolbar( + title = title, + onNewSessionInCwd = requestNewSession, + onEditQuickReplies = { showPaletteEditor = true }, + ) ReconnectBanner(model = banner, onNewSession = requestNewSession) // Cockpit surfaces (A22): away-digest (once per reconnect) + statusLine telemetry chips. Both hide // themselves when their state is null/empty; server strings render inert (§8). @@ -246,12 +286,23 @@ public fun TerminalScreen( remoteView.onKeyCommand = { keyCode, keyEvent -> HardwareKeyRouter.handle(keyCode, keyEvent, controller::sendInput) } - // A layout/size change feeds the latest-writer-wins reclaim path (§6.4). The pixel→ - // grid metric read (Termux's package-private mFontWidth) + emulator resize is - // completed in the view layer on-device; here we reclaim full-screen. - remoteView.onViewSizeChanged = { _, _ -> controller.notifyForegrounded(null, null) } + // The grid for this size has ALREADY been measured and pushed by the view layer + // (TerminalResizeDriver → RemoteTerminalSession.updateSize → `Resize`) by the time + // this fires, so re-asserting it here would only double the frame. Forward it to + // the engine solely while disconnected, where the call is the connect-now nudge + // (§6.4). bannerState is read at CALL time — a value captured in this factory + // lambda would be frozen at first composition. + remoteView.onViewSizeChanged = { _, _ -> + if (TerminalReclaimPolicy.nudgesOnViewSizeChange(holder.bannerState.value)) { + controller.notifyForegrounded(null, null) + } + } controller.remoteSession.attachView(remoteView) - remoteView.hostView() + // The host view IS the View to compose: :terminal-view now exposes its own + // `RemoteTerminalHostView` (a FrameLayout that owns focus + the IME contract and + // keeps the final stock Termux view away from its session-dereferencing paths), so + // no reflection is needed to avoid naming an `implementation`-scoped Termux type. + remoteView.terminalView }, // Config change / real background: unbind the view but the holder-owned emulator // survives (FIX 3) — never close it here (that is the holder teardown's job). @@ -260,10 +311,31 @@ public fun TerminalScreen( } if (covered) PrivacyCover() } + // A25: the quick-reply chips float directly above the key-bar while a gate is held — the moment + // Claude is waiting is exactly when a one-tap canned reply is useful. Each tap sends the chip's + // text VERBATIM through the same ordered send pump as typing (invariant #1/#9). + QuickReply( + chips = quickReplyChips, + gateHeld = gateUi.gate != null, + onSend = controller::sendInput, + ) // The IME-bypass touch key-bar, pinned above the keyboard (hidden on wide screens / hardware kbd). KeyBar(onSend = controller::sendInput) } + // The editable palette (A25). Every mutation goes through the store (the CRUD authority) and the + // presenter republishes what came back, so the chips above can never drift from what was persisted. + if (showPaletteEditor) { + QuickReplyPaletteEditor( + chips = quickReplyChips, + onAdd = { text -> paletteScope.launch { palette.add(text) } }, + onEdit = { id, text -> paletteScope.launch { palette.edit(id, text) } }, + onRemove = { id -> paletteScope.launch { palette.remove(id) } }, + onMove = { from, to -> paletteScope.launch { palette.move(from, to) } }, + onDismiss = { showPaletteEditor = false }, + ) + } + // Plan gate → a three-way ModalBottomSheet overlay. Dismissing (scrim/back) resolves NOTHING — the // gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in // GateViewModel.decide. @@ -275,9 +347,18 @@ public fun TerminalScreen( /** Telemetry-chip staleness tick interval (ms) — re-reads the wall clock so chips grey as a frame ages. */ private const val TELEMETRY_TICK_MS: Long = 1_000L -/** Toolbar: the inert (already-sanitized) session title + the new-session-in-cwd action. */ +/** + * Toolbar: the inert (already-sanitized) session title, the quick-reply palette editor and the + * new-session-in-cwd action. The editor lives HERE rather than on the chip row because the row only + * floats while a gate is held (and hides itself when the palette is empty) — an editor reachable only + * from the row could never be used to create the first chip. + */ @Composable -private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) { +private fun TerminalToolbar( + title: String, + onNewSessionInCwd: () -> Unit, + onEditQuickReplies: () -> Unit, +) { Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -292,6 +373,7 @@ private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + TextButton(onClick = onEditQuickReplies) { Text("快捷回复") } TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") } } } @@ -340,17 +422,65 @@ private tailrec fun Context.findActivity(): Activity? = when (this) { } /** - * The [android.view.View] to add to the Compose tree — the Termux `TerminalView` behind - * [RemoteTerminalView]. + * WHICH app-side events must call `SessionEngine.notifyForegrounded` for the §6.4 latest-writer-wins + * reclaim — the pure, JVM-tested half of the device-switch resize path. * - * BOUNDARY WORKAROUND (recorded): A16's `RemoteTerminalView.terminalView` is typed - * `com.termux.view.TerminalView`, but `:terminal-view` scopes the Termux `terminal-view` artifact as - * `implementation`, so that concrete type is NOT on `:app`'s compile classpath (and neither - * `app/build.gradle.kts` nor `:terminal-view`/`RemoteTerminalView` is in A21's editable lane). We - * therefore read the already-attached stock view through its public getter as a plain [View] — a - * `TerminalView` IS a `View`, so hosting it in an `AndroidView` is correct; only the compile-time type - * name is avoided. The clean fix (out of A21's lane) is to `api`-expose the Termux view from - * `:terminal-view` or add `libs.termux.terminal.view` to `:app`, then `AndroidView { remote.terminalView }`. + * A shared PTY has exactly ONE size, so the device you are actively using has to re-assert its dims to + * take that size back from whichever device wrote last. Two things make this subtler than "resend on every + * event": + * - **The view layer already owns the grid.** `RemoteTerminalHostView.onSizeChanged` → + * `TerminalResizeDriver` → `RemoteTerminalSession.updateSize` measures and pushes the real cols×rows, + * deduping sub-cell layout churn so a rotation costs exactly one `Resize` frame. Calling the engine on + * the SAME size-changed event would double every one of those frames (and re-SIGWINCH the shell). + * - **The engine call has no dedup.** `notifyForegrounded` re-sends the remembered dims unconditionally — + * which is precisely what the reclaim needs, and precisely why it must not be attached to layout churn. + * + * So the engine call is reserved for what the view cannot observe (becoming the active device again: + * `ON_RESUME`, a window-focus GAIN) plus the disconnected case, where the same call is the + * connect-now-without-resetting-the-ladder nudge. */ -private fun RemoteTerminalView.hostView(): View = - RemoteTerminalView::class.java.getMethod("getTerminalView").invoke(this) as View +internal object TerminalReclaimPolicy { + + /** `ON_RESUME` (pane-show / device-switch) is the only lifecycle event that reclaims. */ + fun reclaimsOnLifecycle(event: Lifecycle.Event): Boolean = event == Lifecycle.Event.ON_RESUME + + /** Focus GAIN reclaims; a blur must never resize (the v0.4 min→latest-writer pivot removed that). */ + fun reclaimsOnWindowFocus(focused: Boolean): Boolean = focused + + /** + * A view size change reaches the engine only while a connect is pending/retrying — there the call is + * the connect-now nudge. Connected ([BannerModel.Hidden]) the view driver has already pushed this + * exact grid; terminally failed/exited there is no reconnect owed. + */ + fun nudgesOnViewSizeChange(banner: BannerModel): Boolean = + banner is BannerModel.Connecting || banner is BannerModel.Reconnecting +} + +/** + * The app-graph quick-reply store: a [DataStoreQuickReplyStore] over the SAME + * `DataStore` singleton the host/last-session stores use (disjoint keys, one file — two + * `DataStore` instances over one file would throw). + * + * `TerminalScreen` is a Composable, so it cannot take the store by constructor injection; the store is + * pulled off the Hilt `SingletonComponent` through an `@EntryPoint` instead (the standard Hilt escape + * hatch for exactly this case) and kept as a default parameter value so a test/preview can still pass an + * in-memory store. Nothing here widens the DI graph — the provider already exists in `di/StorageModule`. + */ +@Composable +internal fun rememberAppQuickReplyStore(): QuickReplyStore { + val application = LocalContext.current.applicationContext + return remember(application) { + DataStoreQuickReplyStore( + EntryPointAccessors + .fromApplication(application, QuickReplyStoreEntryPoint::class.java) + .preferencesDataStore(), + ) + } +} + +/** Reads the app-scoped Preferences `DataStore` provider for [rememberAppQuickReplyStore]. */ +@EntryPoint +@InstallIn(SingletonComponent::class) +internal interface QuickReplyStoreEntryPoint { + public fun preferencesDataStore(): DataStore +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt index 9029880..3faf88f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt @@ -17,6 +17,7 @@ import wang.yaojia.webterm.session.Gate import wang.yaojia.webterm.session.GateState import wang.yaojia.webterm.session.SessionEvent import wang.yaojia.webterm.session.Telemetry +import wang.yaojia.webterm.wire.ApprovalPreview import wang.yaojia.webterm.wire.ClientMessage import wang.yaojia.webterm.wire.GateKind import wang.yaojia.webterm.wire.StatusTelemetry @@ -106,13 +107,15 @@ public class GateViewModel( is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry) is Digest -> onDigest(event.digest) // The session ended: a held gate is no longer decidable — clear it so no stale card lingers. - is Exited -> _uiState.value = _uiState.value.copy(gate = null) + is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null) else -> Unit // Output / Connection / Adopted are not cockpit concerns. } } private fun onGate(gate: GateState?) { - _uiState.value = _uiState.value.copy(gate = gate) + // The preview is bound to the gate it describes: it arrives with it and is cleared with it, so + // a resolved gate's command/diff can never sit under the NEXT gate's buttons. + _uiState.value = _uiState.value.copy(gate = gate, preview = gate?.preview) if (gate == null) return // Falling edge: gate lifted; keep lastArrivalEpoch so it can't refire. // Rising edge = a NEW epoch (epochs are monotonic; a refresh keeps the same epoch). if (gate.epoch != lastArrivalEpoch) { @@ -152,10 +155,29 @@ public enum class GateDecision { REJECT, } +/** + * W1 — strip everything that could make untrusted preview text behave like anything other than text: + * every C0 control char except `\n`/`\t`, DEL, and the C1 range. ESC in particular must never reach a + * renderer — and `\n` must survive, because a shell command's line structure IS the content. + * + * The server already sanitizes (`src/http/approval-preview.ts`); this is the client's defence in + * depth, because the gate surfaces are the one place untrusted bytes sit under an approve button. + */ +public fun inertPreviewText(raw: String): String = + raw.filterNot { ch -> + val code = ch.code + (code < 0x20 && ch != '\n' && ch != '\t') || code == 0x7F || (code in 0x80..0x9F) + } + +/** The sanitized command text of a [ApprovalPreview.Command], or null when nothing legible remains. */ +public fun ApprovalPreview.Command.inertText(): String? = inertPreviewText(text).takeIf { it.isNotBlank() } + /** The gate/cockpit snapshot rendered by the surfaces. All fields null = nothing to show. */ public data class GateUiState( /** The held permission gate, or `null` when none (falling edge / never risen). */ val gate: GateState? = null, + /** What the held gate would run, or `null` when the server sent no preview (a NORMAL state). */ + val preview: ApprovalPreview? = null, /** Latest statusLine telemetry, or `null` before the first `telemetry` frame. */ val telemetry: StatusTelemetry? = null, /** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */ 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 cb7e232..5fedf1b 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 @@ -8,14 +8,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingProbeResult -import wang.yaojia.webterm.api.pairing.runPairingProbe import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.wire.HostClassifier import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostNetworkTier -import wang.yaojia.webterm.wire.HttpTransport -import wang.yaojia.webterm.wire.TermTransport import java.util.UUID /** @@ -35,13 +32,23 @@ import java.util.UUID * 3. **Public host needs an explicit acknowledge.** A [WarningTier.PUBLIC] host cannot be probed until * the user ticks the risk acknowledge ([confirm]`(acknowledgedPublicRisk = true)`); otherwise the * confirm is a no-op that re-arms the reminder. - * 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** Both [confirm] and [retry] - * funnel through the ONE private [runProbe]; for a [WarningTier.TUNNEL] host with no installed - * device cert, [runProbe] refuses BEFORE any network I/O and re-refuses on every retry (plan §8 - * "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed"). + * 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** [confirm], [retry] and the + * post-token resume all funnel through the ONE private `performProbe`; for a [WarningTier.TUNNEL] + * host with no installed device cert it refuses BEFORE any network I/O and re-refuses on every + * retry (plan §8 "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is + * installed"). * * On probe success the validated host is persisted via [HostStore.upsert]. * + * ### The `WEBTERM_TOKEN` gate (server `src/http/auth.ts`) + * A token-gated host answers the probe's unauthenticated `GET /live-sessions` with **401**, which the + * frozen probe taxonomy can only report as [PairingError.HttpOkButNotWebTerminal] ("端口对吗?") — so + * without this flow such a host is impossible to pair AND the copy is misleading. On that ambiguous + * failure the ViewModel asks [PairingProber.requiresAccessToken]; a gated host routes to + * [PairingUiState.TokenRequired], and [submitAccessToken] posts the token (`POST /auth`) so the shared + * cookie jar picks up the `webterm_auth` cookie, then resumes pairing through the same choke point. + * The token is a SHELL credential: it is a parameter, never a field and never part of any UI state. + * * @param hostStore where a successfully-paired [Host] is saved. * @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests. * @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO). @@ -126,38 +133,102 @@ public class PairingViewModel( runProbe(candidate, acknowledgedPublicRisk) } + /** + * Submit the host's shared access token (`WEBTERM_TOKEN`). Only meaningful from + * [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never post a + * credential to a host the user has moved on from. + * + * [token] is passed straight through to the prober and is NEVER stored: no field, no UI state, no + * log. On acceptance the shared cookie jar holds `webterm_auth` and pairing resumes through the same + * choke point as [confirm]/[retry] (so RULE 4's cert-gate still applies); every other outcome + * re-arms the prompt with a distinguishable reason and NEVER auto-retries (429 included). + */ + public fun submitAccessToken(token: String) { + val state = _uiState.value + if (state !is PairingUiState.TokenRequired) return + if (token.isBlank()) return + val candidate = Candidate(state.endpoint, state.name, state.tier) + val scope = scope ?: return + probeJob?.cancel() + probeJob = scope.launch { + _uiState.value = PairingUiState.TokenSubmitting(candidate.endpoint, candidate.name, candidate.tier) + when (prober.submitAccessToken(candidate.endpoint, token)) { + // Straight into the probe body: RULE 3 is already satisfied by construction (reaching the + // prompt required a probe, which for a public host required the explicit ack — and an ack + // cannot be un-given), while RULE 4's cert-gate lives INSIDE performProbe and re-applies. + TokenSubmission.Accepted -> performProbe(candidate) + TokenSubmission.Rejected -> promptForToken(candidate, TokenError.INVALID) + TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED) + is TokenSubmission.Unreachable -> promptForToken(candidate, TokenError.UNREACHABLE) + TokenSubmission.Unavailable -> promptForToken(candidate, TokenError.UNAVAILABLE) + } + } + } + private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) { - val tier = candidate.tier - // RULE 3 — public host: no probe until the risk is explicitly acknowledged. - if (tier.needsExplicitAck && !acknowledgedPublicRisk) { + // RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the + // pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe. + if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) { _uiState.value = PairingUiState.Confirming( endpoint = candidate.endpoint, name = candidate.name, - tier = tier, + tier = candidate.tier, awaitingAck = true, ) return } val scope = scope ?: return probeJob?.cancel() - probeJob = scope.launch { - // RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry re-hits this same guard. - if (tier.isCertGated && !hasDeviceCert()) { - _uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier) - return@launch - } - _uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier) - when (val result = prober.probe(candidate.endpoint)) { - is PairingProbeResult.Success -> onProbeSuccess(candidate) - is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed( - endpoint = candidate.endpoint, - name = candidate.name, - tier = tier, - error = result.error, - message = PairingCopy.describe(result.error, tier), - ) - } + probeJob = scope.launch { performProbe(candidate) } + } + + /** + * The ONE probe body — reached from [confirm]/[retry] via [runProbe] and from an accepted access + * token. Holds RULE 4 (the cert-gate) so BOTH entry points hit it; RULE 3 (the public-risk ack) is a + * pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns + * [probeJob], which would cancel the coroutine it is called from). + */ + private suspend fun performProbe(candidate: Candidate) { + val tier = candidate.tier + // RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both + // re-hit this same guard. + if (tier.isCertGated && !hasDeviceCert()) { + _uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier) + return } + _uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier) + when (val result = prober.probe(candidate.endpoint)) { + is PairingProbeResult.Success -> onProbeSuccess(candidate) + is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error) + } + } + + /** + * A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a + * `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token + * gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim. + */ + private suspend fun onProbeFailure(candidate: Candidate, error: PairingError) { + if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) { + promptForToken(candidate, error = null) + return + } + _uiState.value = PairingUiState.Failed( + endpoint = candidate.endpoint, + name = candidate.name, + tier = candidate.tier, + error = error, + message = PairingCopy.describe(error, candidate.tier), + ) + } + + private fun promptForToken(candidate: Candidate, error: TokenError?) { + _uiState.value = PairingUiState.TokenRequired( + endpoint = candidate.endpoint, + name = candidate.name, + tier = candidate.tier, + error = error, + ) } private suspend fun onProbeSuccess(candidate: Candidate) { @@ -181,20 +252,80 @@ public class PairingViewModel( HostClassifier.hostOf(endpoint).ifEmpty { endpoint.baseUrl } } -/** The two-step pairing probe seam ([wang.yaojia.webterm.api.pairing.runPairingProbe]); faked in tests. */ -public fun interface PairingProber { +/** + * The pairing NETWORK seam: the two-step probe ([wang.yaojia.webterm.api.pairing.runPairingProbe]) plus + * the `WEBTERM_TOKEN` gate's two operations. Faked in tests; production is built by + * `AppEnvironment.buildPairingProber()` over the ONE shared `OkHttpClient` (so the token cookie the + * submit obtains is the same jar every later request and the WS upgrade read from). + * + * The two token operations are DEFAULTED so a probe-only double still satisfies the interface. Their + * defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and + * [TokenSubmission.Unavailable] — never a silent success. + */ +public interface PairingProber { public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult + + /** + * Does [endpoint] answer an unauthenticated read with the server's `401 authentication required` + * (`src/server.ts` `authGate`)? Only consulted to disambiguate + * [PairingError.HttpOkButNotWebTerminal]. + * + * Implementations MUST NOT throw for a network failure — an unreachable host is `false` ("not known + * to be gated"), so the app never prompts for a credential because a host is merely down. + */ + public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = false + + /** + * `POST /auth` with [token]. On [TokenSubmission.Accepted] the shared cookie jar holds the host's + * `webterm_auth` cookie, so the very next probe/attach is authenticated. + * + * [token] is a SHELL CREDENTIAL: implementations must put it in the request BODY only — never a URL, + * never a log, never an exception message — and must not retain it. + */ + public suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission = + TokenSubmission.Unavailable } /** - * Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared - * `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav - * layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel — - * `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink - * I/O on a UI thread. + * Outcome of `POST /auth` (server `src/http/auth.ts` + the route in `src/server.ts`). A wrong token + * ([Rejected]) and a host that did not answer ([Unreachable]) are DISTINCT cases on purpose — collapsing + * them would tell the user to re-type a token that was fine. */ -public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber = - PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) } +public sealed interface TokenSubmission { + /** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */ + public data object Accepted : TokenSubmission + + /** `401` — the token does not match the host's `WEBTERM_TOKEN`. */ + public data object Rejected : TokenSubmission + + /** `429` — over the host's 10-per-minute auth limit. Surfaced as-is; NEVER auto-retried. */ + public data object RateLimited : TokenSubmission + + /** + * The submit never got an answer it could interpret: a transport failure or an unexpected status. + * [reason] is a short, credential-free diagnostic (an error TYPE or `"HTTP "`) — it is + * shown to nobody and logged nowhere; the UI copy is app-authored. + */ + public data class Unreachable(val reason: String) : TokenSubmission + + /** This prober has no way to submit a token (a probe-only seam). Reported, never silently ignored. */ + public data object Unavailable : TokenSubmission +} + +/** Why the access-token prompt is showing an error. Maps to inert, app-authored copy in [PairingCopy]. */ +public enum class TokenError { + /** The submitted token was rejected by the host (`401`). */ + INVALID, + + /** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */ + RATE_LIMITED, + + /** The host did not answer the submit (or answered unintelligibly). */ + UNREACHABLE, + + /** The app cannot submit a token at all on this seam. */ + UNAVAILABLE, +} // ── Warning tiers (plan §5.4) ─────────────────────────────────────────────────────────────────────── @@ -276,6 +407,27 @@ public sealed interface PairingUiState { */ public data class CertRequired(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState + /** + * The host is behind the optional `WEBTERM_TOKEN` gate and needs its shared access token before + * pairing can continue. [error] is null on the first prompt and set after a failed submit. + * + * This state deliberately carries NO token field — the credential is a + * [PairingViewModel.submitAccessToken] parameter and must not survive the call (plan §8). + */ + public data class TokenRequired( + val endpoint: HostEndpoint, + val name: String, + val tier: WarningTier, + val error: TokenError? = null, + ) : PairingUiState + + /** A submitted access token is in flight (`POST /auth`). Carries no token, for the same reason. */ + public data class TokenSubmitting( + val endpoint: HostEndpoint, + val name: String, + val tier: WarningTier, + ) : PairingUiState + /** The probe failed; [message] is inert, app-authored copy for [error]. Retryable. */ public data class Failed( val endpoint: HostEndpoint, @@ -302,6 +454,8 @@ private fun PairingUiState.candidate(): Candidate? = when (this) { is PairingUiState.Confirming -> Candidate(endpoint, name, tier) is PairingUiState.Probing -> Candidate(endpoint, name, tier) is PairingUiState.CertRequired -> Candidate(endpoint, name, tier) + is PairingUiState.TokenRequired -> Candidate(endpoint, name, tier) + is PairingUiState.TokenSubmitting -> Candidate(endpoint, name, tier) is PairingUiState.Failed -> Candidate(endpoint, name, tier) is PairingUiState.Entry, is PairingUiState.Paired -> null } @@ -315,11 +469,21 @@ internal object PairingCopy { "这个端口在运行别的服务,不是 web-terminal。端口对吗?" is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived is PairingError.CleartextBlocked -> - // 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 —— 该主机由设备证书保护,不允许降级为明文。" + // Two distinct refusals collapse into this one error case (both surface as + // UnknownServiceException, which is what `PairingError.classify` keys on): + // 1. the PLATFORM's network_security_config, which pins the tunnel apex to TLS because it + // always serves a real LE cert (LAN cleartext itself is permitted — the config format has + // no CIDR syntax, so it could not be scoped to RFC1918); + // 2. the app's OWN transport-level cleartext guard (:transport-okhttp + // CleartextNotPermittedException), which is where the CIDR scoping actually happens: a + // plaintext dial to a PUBLIC (or unclassifiable) host is refused before it leaves the + // device. The tier tells the two apart, so each gets copy the user can act on. + if (tier == WarningTier.TUNNEL) { + "${error.host} 必须使用 https / wss —— 该主机由设备证书保护,不允许降级为明文。" + } else { + "应用拒绝以明文连接 ${error.host}:http:// 与 ws:// 只允许用于本机、局域网和 Tailscale 地址。" + + "公网主机请改用 https:// / wss://(隧道或 Tailscale)。" + } 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"). @@ -328,4 +492,19 @@ internal object PairingCopy { PairingError.Timeout -> "连接超时。主机可能不可达,或响应过慢。" } + + /** + * Copy for the access-token prompt. A wrong token and an unreachable host read differently on + * purpose, and the rate-limit line tells the user to WAIT — the app never retries by itself. + */ + fun describeToken(error: TokenError): String = when (error) { + TokenError.INVALID -> + "访问令牌不正确。请核对主机上 WEBTERM_TOKEN 的值(区分大小写)后重试。" + TokenError.RATE_LIMITED -> + "尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。" + TokenError.UNREACHABLE -> + "提交访问令牌时主机没有正常响应。请检查网络与地址后重试。" + TokenError.UNAVAILABLE -> + "当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。" + } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt index 2ac6b27..8bc94cc 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt @@ -4,9 +4,15 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import wang.yaojia.webterm.api.models.CommitLogEntry +import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.GitLogResult +import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PrStatus import wang.yaojia.webterm.api.models.ProjectDetail +import wang.yaojia.webterm.api.models.SyncState +import wang.yaojia.webterm.api.models.WorktreeInfo +import wang.yaojia.webterm.api.models.WorktreeState import wang.yaojia.webterm.api.routes.ApiClientError /** @@ -30,6 +36,16 @@ public class ProjectDetailViewModel( private val fetchLog: (suspend () -> GitLogResult)? = null, /** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */ public val worktree: WorktreeViewModel? = null, + /** + * w6/G2 — `POST /projects/git/fetch` for THIS repo. `null` ⇒ the band hides the Fetch action + * rather than offering a button that cannot work. + */ + private val fetchRemote: (suspend () -> GitWriteOutcome)? = null, + /** + * w6/G7 — `GET /projects/worktree/state?path=` for ONE worktree. `null` ⇒ rows stay unprobed and + * show no sync chip, which is the honest rendering of "not known". + */ + private val fetchWorktreeState: (suspend (String) -> WorktreeState)? = null, ) { /** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */ public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE } @@ -57,9 +73,23 @@ public class ProjectDetailViewModel( public data object Unavailable : RecentCommits } + /** w6/G2 — the Fetch button's own state. A failure NEVER touches [phase] (failure isolation). */ + public sealed interface FetchPhase { + public data object Idle : FetchPhase + public data object Working : FetchPhase + + /** Succeeded; [lastFetchMs] is the server's post-fetch `FETCH_HEAD` mtime (may be null). */ + public data class Done(val lastFetchMs: Long?) : FetchPhase + + /** Failed (offline / auth / ≥2 remotes). `lastFetchMs` is left untouched so `stale` stays on. */ + public data class Failed(val message: String) : FetchPhase + } + private val _phase = MutableStateFlow(Phase.Loading) private val _prChip = MutableStateFlow(PrChip.Hidden) private val _recentCommits = MutableStateFlow(RecentCommits.Hidden) + private val _fetchPhase = MutableStateFlow(FetchPhase.Idle) + private val _worktreeStates = MutableStateFlow>(emptyMap()) /** The main detail snapshot `ProjectDetailScreen` renders from. */ public val phase: StateFlow = _phase.asStateFlow() @@ -70,6 +100,15 @@ public class ProjectDetailViewModel( /** The recent-commits snapshot. */ public val recentCommits: StateFlow = _recentCommits.asStateFlow() + /** The Fetch action's snapshot (spinner / result banner). */ + public val fetchPhase: StateFlow = _fetchPhase.asStateFlow() + + /** Per-worktree git state, keyed by worktree path. Absent ⇒ unprobed (renders no sync chip). */ + public val worktreeStates: StateFlow> = _worktreeStates.asStateFlow() + + /** Whether a Fetch seam is wired at all; a detached HEAD is refused by [SyncBand.canFetch]. */ + public val canFetch: Boolean get() = fetchRemote != null + /** * Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful * detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail @@ -93,6 +132,72 @@ public class ProjectDetailViewModel( } } + /** + * w6/G2 — refresh the remote-tracking refs for this repo (`git fetch --no-tags`: no pull, no + * merge, no working-tree change). Rules, all pinned by test: + * - **one at a time** — a tap while [FetchPhase.Working] is DROPPED, never queued; + * - **success re-loads the detail**, because `behind`/`lastFetchMs` only move server-side; + * - **failure changes nothing** — no re-load, no invented `lastFetchMs`, so the band's `stale` + * flag stays on. Marking stale data fresh is the one lie this panel exists to prevent. + */ + public suspend fun fetchUpstream() { + val fetcher = fetchRemote ?: return + if (_fetchPhase.value == FetchPhase.Working) return + _fetchPhase.value = FetchPhase.Working + val outcome = try { + fetcher() + } catch (cancel: CancellationException) { + _fetchPhase.value = FetchPhase.Idle + throw cancel + } catch (_: Throwable) { + _fetchPhase.value = FetchPhase.Failed(GitPanelCopy.FETCH_FAILED) + return + } + when (outcome) { + is GitWriteOutcome.Ok -> { + _fetchPhase.value = FetchPhase.Done(outcome.payload.lastFetchMs) + load() // the numbers this button exists for live server-side — re-read them. + } + // The server classifies and sanitizes its own failures (SEC-M10), so its message is shown + // verbatim; a missing one falls back to local copy. Nothing else changes: `lastFetchMs` + // stays where it was, so the band keeps reading "stale" instead of pretending it refreshed. + is GitWriteOutcome.Rejected -> + _fetchPhase.value = FetchPhase.Failed(outcome.message ?: GitPanelCopy.FETCH_FAILED) + GitWriteOutcome.RateLimited -> + _fetchPhase.value = FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED) + } + } + + /** + * w6/G7 — probe the git state of every worktree OTHER than the current one (the current row reads + * the project's own `sync`/`dirtyCount`, already loaded and free). + * + * Each probe is isolated to its own row: a failure leaves that row unprobed (no chip) and never + * touches [phase] or another row. Probed once per opened project, never per refresh tick. + */ + public suspend fun probeWorktrees() { + val probe = fetchWorktreeState ?: return + val detail = (_phase.value as? Phase.Loaded)?.detail ?: return + for (worktree in detail.worktrees) { + if (worktree.isCurrent) continue + val state = try { + probe(worktree.path) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + continue // isolated: this row stays unprobed, the panel still renders + } + _worktreeStates.value = _worktreeStates.value + ( + worktree.path to WorktreeRowState(sync = state.sync, dirtyCount = state.dirtyCount) + ) + } + } + + /** Dismiss the fetch result banner. */ + public fun clearFetchBanner() { + _fetchPhase.value = FetchPhase.Idle + } + private suspend fun loadPr() { val fetcher = fetchPr ?: return _prChip.value = PrChip.Loading @@ -137,9 +242,218 @@ public class ProjectDetailViewModel( fetchPr = { gateway.projectPr(path) }, fetchLog = { gateway.projectLog(path, null) }, worktree = worktree, + fetchRemote = { gateway.gitFetch(path) }, + fetchWorktreeState = { gateway.worktreeState(it) }, ) self = vm return vm } } } + +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// w6 — the git panel's pure presentation core (port of public/projects.ts `makeSyncBand` / +// `makeWorktreeRow` + public/git-log.ts `renderGitLog`; design: docs/mockups/project-detail-git.html). +// Pure and JVM-tested; the Compose layer only paints what these return. +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +/** [GitLogResult.boundaryIndex] when there is no pushed/unpushed boundary to draw. */ +public const val NO_COMMIT_BOUNDARY: Int = -1 + +/** + * The header sync band, as one of three mutually exclusive shapes. + * + * **The rule the type exists to enforce: exactly ONE shape may ever answer [isAllClear] true** — + * [Tracking] with `ahead == 0 && behind == 0` and a fetch inside [SyncState.FETCH_FRESH_WINDOW_MS]. + * Green here means + * "I checked, ignore this", so [Detached] and [NoUpstream] cannot reach it by construction: absent + * numbers are not zeros. + */ +public sealed interface SyncBand { + /** True for the single green "all clear" state and nothing else. */ + public val isAllClear: Boolean + + /** `git fetch` is meaningless without a branch, so a detached HEAD disables the button. */ + public val canFetch: Boolean get() = this !is Detached + + /** HEAD is detached: show the short sha, no branch, no ↑↓. */ + public data class Detached(val head: String?) : SyncBand { + override val isAllClear: Boolean get() = false + } + + /** The branch tracks nothing — stated explicitly, in words as well as colour. */ + public data object NoUpstream : SyncBand { + override val isAllClear: Boolean get() = false + } + + /** Tracking [upstream]: ↑[ahead] is fact, ↓[behind] is only as fresh as [lastFetchMs]. */ + public data class Tracking( + val upstream: String, + val ahead: Int, + val behind: Int, + /** `FETCH_HEAD` older than [SyncState.FETCH_FRESH_WINDOW_MS] (or never) ⇒ [behind] is a guess. */ + val isStale: Boolean, + val lastFetchMs: Long?, + ) : SyncBand { + override val isAllClear: Boolean get() = ahead == 0 && behind == 0 && !isStale + } +} + +/** + * Derive the header band. `null` facts (a non-git project) ⇒ `null` — say nothing rather than guess. + * [head] is the short HEAD sha shown on a detached HEAD. + */ +public fun syncBandOf( + sync: SyncState?, + head: String? = null, + nowMs: Long = System.currentTimeMillis(), +): SyncBand? { + if (sync == null) return null + if (sync.detached) return SyncBand.Detached(head) + // A blank upstream is treated as none: the server should never send one, and "" would otherwise + // label the commit boundary with an empty string and unlock the green path. + val upstream = sync.upstream?.trim()?.takeIf { it.isNotEmpty() } ?: return SyncBand.NoUpstream + return SyncBand.Tracking( + upstream = upstream, + ahead = sync.ahead ?: 0, + behind = sync.behind ?: 0, + isStale = !sync.isFetchFresh(nowMs), + lastFetchMs = sync.lastFetchMs, + ) +} + +/** + * The header's uncommitted-changes chip, or null when clean/unknown. A count is strictly better than + * a bare dot (`● 3 未提交` answers "how much?"), so it wins whenever the server sent one; `dirty` + * alone falls back to the countless chip (mirror of public/projects.ts:1254-1258). + */ +public fun headerDirtyChip(dirtyCount: Int?, dirty: Boolean?): String? = when { + dirtyCount != null && dirtyCount > 0 -> GitPanelCopy.dirtyChip(dirtyCount) + dirtyCount == null && dirty == true -> GitPanelCopy.DIRTY_NO_COUNT + else -> null +} + +// ── Commit list: unpushed marking + ONE upstream boundary ──────────────────────────── + +/** + * The label for the pushed/unpushed boundary, or null when NO boundary may be drawn. + * + * A blank upstream counts as absent: an empty label would be a boundary claiming a position on the + * timeline without naming what it is the position OF. + */ +public fun GitLogResult.boundaryLabel(): String? = upstream?.trim()?.takeIf { it.isNotEmpty() } + +/** + * The row index the boundary is drawn AFTER — exactly once — or [NO_COMMIT_BOUNDARY]. + * + * It is the LAST marked row, not "the first N rows": `git log` is date-ordered and a merge of an older + * branch interleaves unpushed commits BELOW pushed ones, so a row-count rule fails in the dangerous + * direction (calling an unpushed commit pushed). Marking itself is a SERVER decision + * ([GitLogResult.upstreamBoundaryIndex] reads `unpushed`, honoured only when strictly `true`) because + * `git log`'s `%h` is abbreviated while `rev-list` yields full SHAs. + */ +public fun GitLogResult.boundaryIndex(): Int = + if (boundaryLabel() == null) NO_COMMIT_BOUNDARY else upstreamBoundaryIndex ?: NO_COMMIT_BOUNDARY + +/** Whether this row draws the "not pushed yet" rail: only an explicit `true` counts. */ +public fun CommitLogEntry.isUnpushed(): Boolean = unpushed == true + +// ── Worktree rows ──────────────────────────────────────────────────────────────────────────────── + +/** Chip colour role. [OK] is the green all-clear and is reserved for the header band's in-sync chip. */ +public enum class GitChipTone { NEUTRAL, ACCENT, WARN, DIRTY, OK } + +/** One inert chip on a worktree row. */ +public data class WorktreeChip(val label: String, val tone: GitChipTone) + +/** + * Per-row git state (w6/G7 — probed per worktree). Absent means UNPROBED, and an unprobed row shows + * no chip at all rather than a guess. + */ +public data class WorktreeRowState( + val sync: SyncState? = null, + val dirtyCount: Int? = null, + /** Live sessions whose cwd sits inside this worktree. */ + val sessionCount: Int = 0, +) + +/** + * The chips for one worktree row, in the shipped order: main · current · locked · prunable · sync · + * dirty (mirror of public/projects.ts `makeWorktreeRow`). + * + * Deliberately NO green chip here: a worktree row cannot earn the all-clear, because the only state + * that may read green needs a fresh fetch, and "no upstream" — the normal state of a fresh worktree + * branch — must read as a warning instead. + */ +public fun worktreeChips(worktree: WorktreeInfo, state: WorktreeRowState? = null): List { + val chips = ArrayList(6) + if (worktree.isMain) chips += WorktreeChip(GitPanelCopy.MAIN_CHIP, GitChipTone.NEUTRAL) + if (worktree.isCurrent) chips += WorktreeChip(GitPanelCopy.CURRENT_CHIP, GitChipTone.ACCENT) + if (worktree.locked == true) chips += WorktreeChip(GitPanelCopy.LOCKED_CHIP, GitChipTone.NEUTRAL) + if (worktree.prunable == true) chips += WorktreeChip(GitPanelCopy.PRUNABLE_CHIP, GitChipTone.WARN) + val sync = state?.sync + if (sync != null) { + val ahead = sync.ahead ?: 0 + when { + sync.detached -> chips += WorktreeChip(GitPanelCopy.DETACHED_CHIP_SHORT, GitChipTone.WARN) + sync.upstream.isNullOrBlank() -> + chips += WorktreeChip(GitPanelCopy.NO_UPSTREAM_CHIP, GitChipTone.WARN) + ahead > 0 -> chips += WorktreeChip(GitPanelCopy.aheadChip(ahead), GitChipTone.ACCENT) + } + } + val dirtyCount = state?.dirtyCount ?: 0 + if (dirtyCount > 0) chips += WorktreeChip(GitPanelCopy.dirtyCountChip(dirtyCount), GitChipTone.DIRTY) + return chips +} + +/** User-visible copy for the git panel. Local UI text — no wire contract depends on these strings. */ +public object GitPanelCopy { + // Sync band + public const val UPSTREAM_CAPTION: String = "Upstream" + public const val TO_PUSH_CAPTION: String = "待推送" + public const val TO_PULL_CAPTION: String = "待拉取" + public const val HEAD_CAPTION: String = "HEAD" + public const val NO_UPSTREAM_CHIP: String = "无 upstream" + public const val NO_UPSTREAM_NOTE: String = "该分支没有跟踪上游 —— 没有可报告的 ↑↓" + public const val DETACHED_CHIP: String = "detached HEAD" + public const val DETACHED_CHIP_SHORT: String = "detached" + public const val IN_SYNC_CHIP: String = "✓ 已同步" + public const val STALE_CHIP: String = "存疑" + public const val NEVER_FETCHED_NOTE: String = "从未 fetch —— 这个数字不可信" + public const val STALE_NOTE: String = "上次 fetch 已超过一小时 —— 这个数字不可信" + public const val UP_TO_DATE_NOTE: String = "与远端一致" + public const val WAITING_ON_REMOTE_NOTE: String = "远端有新提交" + public const val NOTHING_TO_PUSH_NOTE: String = "没有待推送的提交" + public const val DIRTY_NO_COUNT: String = "● 未提交" + + // Fetch + public const val FETCH: String = "Fetch" + public const val FETCH_WORKING: String = "正在 fetch…" + public const val FETCH_DONE: String = "已刷新远端引用(未 pull、未 merge)" + public const val FETCH_FAILED: String = "fetch 失败,远端状态仍是上次的数据。" + public const val FETCH_RATE_LIMITED: String = "fetch 太频繁,请稍后再试。" + public const val FETCH_DISABLED_DETACHED: String = "HEAD 游离,无法 fetch" + + // Commit list + public const val RECENT_COMMITS: String = "最近提交" + public const val UNPUSHED_MARK: String = "↑" + + // Worktrees + public const val MAIN_CHIP: String = "main" + public const val CURRENT_CHIP: String = "当前" + public const val LOCKED_CHIP: String = "locked" + public const val PRUNABLE_CHIP: String = "prunable" + + /** ALWAYS "Worktrees (n)" — one worktree per session means the count is the point, and a section + * that renames itself at n=1 hides the whole model (w6/G5). */ + public fun worktreesTitle(count: Int): String = "Worktrees ($count)" + + public fun dirtyChip(count: Int): String = "● $count 未提交" + public fun dirtyCountChip(count: Int): String = "● $count" + public fun aheadChip(count: Int): String = "↑ $count" + public fun behindChip(count: Int): String = "↓ $count" + public fun unpushedBadge(count: Int): String = "↑ $count 未推送" + public fun aheadNote(count: Int): String = "$count 个提交只在本机" + public fun sessionCount(count: Int): String = "● $count session" + public fun truncatedNote(count: Int): String = "只显示最近 $count 条提交。" +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt index ef6105e..2bc5fef 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import wang.yaojia.webterm.api.models.CreateWorktreeResult +import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.GitLogResult import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PrStatus @@ -14,6 +15,7 @@ import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.PruneWorktreesResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult import wang.yaojia.webterm.api.models.UiPrefs +import wang.yaojia.webterm.api.models.WorktreeState import wang.yaojia.webterm.api.routes.ApiClient import wang.yaojia.webterm.api.routes.ApiClientError import wang.yaojia.webterm.wire.Validation @@ -418,6 +420,18 @@ public interface ProjectsGateway { public suspend fun projectPr(path: String): PrStatus public suspend fun projectLog(path: String, n: Int? = null): GitLogResult + /** + * w6/G7 — one worktree's git state. Its own call (not part of `projectDetail`) so N rows do not + * each pay for a worktree listing and a CLAUDE.md read. + */ + public suspend fun worktreeState(worktreePath: String): WorktreeState + + /** + * w6/G2 — refresh remote-tracking refs (no pull, no merge). GUARDED like the other git writes: + * the server derives the remote itself and never accepts one from the client. + */ + public suspend fun gitFetch(path: String): GitWriteOutcome + // ── W5: guarded worktree write ───────────────────────────────────────────────────────── public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome @@ -432,6 +446,8 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path) override suspend fun projectPr(path: String): PrStatus = api.projectPr(path) override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n) + override suspend fun worktreeState(worktreePath: String): WorktreeState = api.worktreeState(worktreePath) + override suspend fun gitFetch(path: String): GitWriteOutcome = api.gitFetch(path) override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome = api.createWorktree(path, branch, base) diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt index 7ef05a7..b2ffeb5 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt @@ -1,15 +1,27 @@ package wang.yaojia.webterm.wiring import dagger.Lazy +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import wang.yaojia.webterm.api.pairing.PairingProbeResult import wang.yaojia.webterm.api.pairing.runPairingProbe +import wang.yaojia.webterm.hostregistry.AuthCookieRecord +import wang.yaojia.webterm.hostregistry.AuthCookieStore import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.LastSessionStore import wang.yaojia.webterm.tlsandroid.IdentityRepository +import wang.yaojia.webterm.transport.AuthCookieJar +import wang.yaojia.webterm.transport.AuthCookieSnapshot import wang.yaojia.webterm.viewmodels.PairingProber +import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.TermTransport +import java.net.URI +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton @@ -28,10 +40,11 @@ import javax.inject.Singleton * Engine state is never touched off-confinement and emulator/scrollback state never off-`Main`. * * ### Off-`Main` warm-up (FIX 3) - * The mTLS transports and [identityRepository] are behind `dagger.Lazy` so constructing this root does - * NO AndroidKeyStore/Tink/`OkHttpClient` I/O on the injection (often `Main`) thread. [warmUp] resolves - * them on `Dispatchers.IO`, so the shared client is built and the device identity first-touched off the - * UI thread. A21/A27 call [warmUp] before the first `bind()` / cert-screen open. + * The mTLS transports, [identityRepository], the auth-cookie store and the thumbnail rasterizer are all + * behind `dagger.Lazy` so constructing this root does NO AndroidKeyStore/Tink/`OkHttpClient`/DataStore/ + * `android.graphics` work on the injection (often `Main`) thread — `MainActivity` field-injects it in + * `onCreate`. [warmUp] resolves them on `Dispatchers.IO`, so the shared client is built, the device + * identity first-touched and the persisted session cookie rehydrated off the UI thread. * * What lives here (all app singletons): * - [hostStore] / [lastSessionStore] — persisted, non-secret UI state (DataStore). @@ -39,6 +52,7 @@ import javax.inject.Singleton * - [apiClientFactory] / [sessionEngineFactory] — per-host / per-session builders over the shared * transports (lazy). * - [coldStartPolicy] — the cold-launch route seam (A29). + * - the `webterm_auth` cookie jar + its durable store — the optional `WEBTERM_TOKEN` gate. * Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its * [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder]. */ @@ -57,7 +71,23 @@ public class AppEnvironment @Inject constructor( private val identityRepositoryLazy: Lazy, private val httpTransportLazy: Lazy, private val termTransportLazy: Lazy, + /** The SAME jar instance installed on the one shared `OkHttpClient` (`di/AuthCookieModule`). */ + private val authCookieJarLazy: Lazy, + /** Durable, Tink-AEAD-encrypted home of the `webterm_auth` cookie (`di/AuthCookieModule`). */ + private val authCookieStoreLazy: Lazy, + /** + * The off-screen preview painter (§6.7). `Lazy` because constructing it measures a `Paint` — an + * `android.graphics` touch that must not happen while the Hilt graph is being built (it is stubbed + * under JVM unit tests). + */ + private val thumbnailRasterizerLazy: Lazy, ) { + /** One-shot cold-start restore of the persisted session cookie into the live jar (see [warmUp]). */ + private val authCookieHydrator = AuthCookieHydrator( + store = { authCookieStoreLazy.get() }, + jar = { authCookieJarLazy.get() }, + ) + /** * The mTLS device identity (A27 cert screen). Resolving the [Lazy] merely constructs the repository * (I/O-free per A11); its FIRST touch (`currentSummary`/`hasInstalledIdentity`/handshake) does the @@ -74,26 +104,254 @@ public class AppEnvironment @Inject constructor( public val httpTransport: HttpTransport get() = httpTransportLazy.get() /** - * Build the two-step pairing prober (A19) over the ONE shared transports. The transports are - * resolved LAZILY inside `probe()` (not at build time) so constructing the prober on the UI thread - * never triggers the mTLS/OkHttp build — the actual resolve happens in the probe's own coroutine, - * after [warmUp]. + * Build the pairing network seam (A19) over the ONE shared transports: the two-step probe plus the + * `WEBTERM_TOKEN` gate's detect + submit. The transports are resolved LAZILY inside each call (not at + * build time) so constructing the prober on the UI thread never triggers the mTLS/OkHttp build — the + * actual resolve happens in the caller's own coroutine, after [warmUp]. + * + * The submit rides the SAME client as everything else, which is the whole point: the `webterm_auth` + * cookie it earns lands in the shared [AuthCookieJar] and is therefore replayed on every later REST + * call AND on the WS upgrade. */ - public fun buildPairingProber(): PairingProber = - PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) } + public fun buildPairingProber(): PairingProber = object : PairingProber { + private val gateway = AccessTokenGateway { httpTransportLazy.get() } + + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult = + runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) + + override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = + gateway.requiresAccessToken(endpoint) + + override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission = + gateway.submit(endpoint, token) + } /** - * Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving - * either transport builds the one shared client (which reads the mTLS material); touching the - * identity forces its first AndroidKeyStore/Tink read. Idempotent at the singleton level — the - * client/identity are built once and cached. Safe to call from any coroutine; hops to - * `Dispatchers.IO` internally. + * Build the session-list / manage-page thumbnail pipeline for one host (§6.7). Per-host because its + * preview fetch is bound to that host's [ApiClient][wang.yaojia.webterm.api.routes.ApiClient] + * (`Origin` is stamped per endpoint), so it cannot be an app singleton. + * + * The CALLER owns the returned pipeline's lifetime: keep it in a `remember(env, hostId)` and call + * `close()` from a `DisposableEffect` so its in-flight renders are cancelled with the screen. + * Resolving the rasterizer measures a `Paint` — cheap, but `android.graphics`, so call this from the + * composition, never from DI construction. + */ + public fun buildThumbnailPipeline(endpoint: HostEndpoint): ThumbnailPipeline = + ThumbnailPipeline( + previewSource = ApiPreviewSource { apiClientFactory.create(endpoint) }, + rasterizer = thumbnailRasterizerLazy.get(), + ) + + /** + * Build the shared `OkHttpClient`, rehydrate the persisted session cookie and first-touch the device + * identity OFF `Main` (FIX 3). Resolving either transport builds the one shared client (which reads + * the mTLS material); touching the identity forces its first AndroidKeyStore/Tink read; the cookie + * hydration reads DataStore + Tink. Idempotent at the singleton level — the client/identity are built + * once and cached, and the hydration is one-shot ([AuthCookieHydrator]). Safe to call from any + * coroutine; hops to `Dispatchers.IO` internally. */ public suspend fun warmUp() { withContext(Dispatchers.IO) { + // First: put the stored credential in the jar, so the very first dial is already authenticated + // on a token-gated host (the jar is consulted per request, so this races nothing). + authCookieHydrator.hydrateOnce() sessionEngineFactory.warm() // builds TermTransport → the shared OkHttpClient → reads SSL material apiClientFactory.warm() // HttpTransport reuses the SAME already-built client runCatching { identityRepositoryLazy.get().hasInstalledIdentity() } // first AndroidKeyStore/Tink touch } } } + +// ── The WEBTERM_TOKEN gate (server src/http/auth.ts) ───────────────────────────────────────────────── + +/** + * The client half of the server's optional shared-access-token gate. + * + * Server contract (`src/http/auth.ts` + the `/auth` route in `src/server.ts`): + * - with `WEBTERM_TOKEN` set, EVERY remote route (and the WS upgrade) needs the `webterm_auth` cookie; + * an unauthed non-HTML request gets **401** with an `authentication required` JSON body; + * - `POST /auth` is always reachable and rate-limited to 10/min/IP. A non-HTML request answers **204** + * with `Set-Cookie: webterm_auth=…` on a match, **401** on a mismatch, **429** over the limit; + * - `GET /?token=` exists too and is deliberately NOT used here — it would put a shell credential in a + * URL (access logs, `Referer`, history). + * + * The cookie itself is never handled here: the shared client's [AuthCookieJar] captures `Set-Cookie` and + * replays it, so "the submit succeeded" and "the credential is held" are the same event. + * + * @param http resolves the shared [HttpTransport] LAZILY (resolving it builds the mTLS `OkHttpClient`, so + * it must happen inside the calling coroutine, not when this gateway is constructed). + */ +public class AccessTokenGateway(private val http: () -> HttpTransport) { + + /** + * Is [endpoint] behind the token gate? Probes the unauthenticated read and looks for exactly 401. + * + * Never throws for a network problem: an unreachable host answers `false`, so a host that is merely + * down can never make the app ask the user for a credential. Cancellation still propagates. + */ + public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean { + val response = try { + http().send(HttpRequest(method = HttpMethod.GET, url = endpoint.hostBaseUrl() + LIVE_SESSIONS_PATH)) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + return false + } + return response.status == HTTP_UNAUTHORIZED + } + + /** + * `POST /auth` with [token] in an urlencoded form body. + * + * [token] appears in the BODY only — never the URL, never a header, never the returned outcome, never + * a log line — and nothing here retains it. + */ + public suspend fun submit(endpoint: HostEndpoint, token: String): TokenSubmission { + val request = HttpRequest( + method = HttpMethod.POST, + url = endpoint.hostBaseUrl() + AUTH_PATH, + // No `Accept: text/html`: the server answers an HTML request with 302 redirects instead of + // the 204/401 status pair this mapping depends on. No `Origin` either — /auth is registered + // before the gate and is not an Origin-guarded route (plan §4.3: Origin only where required). + headers = mapOf(CONTENT_TYPE_HEADER to FORM_CONTENT_TYPE), + body = "$TOKEN_FIELD=${formEncode(token)}".toByteArray(Charsets.UTF_8), + ) + val response = try { + http().send(request) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + // The TYPE only: an exception message is host-influenced and could echo the request we sent. + return TokenSubmission.Unreachable(reason = error::class.simpleName ?: UNKNOWN_ERROR) + } + return when { + response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES -> TokenSubmission.Accepted + response.status == HTTP_UNAUTHORIZED -> TokenSubmission.Rejected + response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited + else -> TokenSubmission.Unreachable(reason = "HTTP ${response.status}") + } + } + + private companion object { + const val LIVE_SESSIONS_PATH = "/live-sessions" + const val AUTH_PATH = "/auth" + const val TOKEN_FIELD = "token" + const val CONTENT_TYPE_HEADER = "Content-Type" + const val FORM_CONTENT_TYPE = "application/x-www-form-urlencoded" + const val HTTP_OK = 200 + const val HTTP_MULTIPLE_CHOICES = 300 + const val HTTP_UNAUTHORIZED = 401 + const val HTTP_TOO_MANY_REQUESTS = 429 + const val UNKNOWN_ERROR = "unknown" + } +} + +/** + * `scheme://host[:port]` from the dialed URL — path/query/fragment/credentials dropped. Mirrors + * `:api-client`'s probe derivation (private there, hence re-derived rather than shared); the dialed port + * is kept verbatim and IPv6 literals arrive already bracketed from [java.net.URI]. + */ +private fun HostEndpoint.hostBaseUrl(): String { + val uri = URI(baseUrl) + val scheme = (uri.scheme ?: "http").lowercase() + val host = uri.host ?: "" + val portPart = if (uri.port != -1) ":${uri.port}" else "" + return "$scheme://$host$portPart" +} + +/** + * Percent-encode a form-field value, allowing ONLY RFC 3986 unreserved characters through. + * + * Stricter than necessary on purpose: the server's token charset (`[A-Za-z0-9._~+/=-]`) includes `+`, + * `/` and `=`, and in an `application/x-www-form-urlencoded` body a raw `+` decodes to a SPACE while `=` + * would split the field — so a correct token would be rejected. `java.net.URLEncoder` is avoided for the + * same reason (it emits `+` for spaces). + */ +private fun formEncode(value: String): String { + val out = StringBuilder(value.length) + for (byte in value.toByteArray(Charsets.UTF_8)) { + val octet = byte.toInt() and BYTE_MASK + val char = octet.toChar() + if (char.isUnreservedUrlChar()) { + out.append(char) + } else { + // Hand-rolled hex, NOT String.format: `%02X` is locale-sensitive and would emit non-ASCII + // digits under some locales, corrupting the credential. + out.append('%').append(HEX_DIGITS[octet shr HEX_SHIFT]).append(HEX_DIGITS[octet and LOW_NIBBLE]) + } + } + return out.toString() +} + +private fun Char.isUnreservedUrlChar(): Boolean = + this in 'A'..'Z' || this in 'a'..'z' || this in '0'..'9' || this in "-._~" + +private const val HEX_DIGITS: String = "0123456789ABCDEF" +private const val BYTE_MASK: Int = 0xFF +private const val LOW_NIBBLE: Int = 0x0F +private const val HEX_SHIFT: Int = 4 + +// ── Auth-cookie durability bridge (:transport-okhttp ⇄ :host-registry) ─────────────────────────────── + +/** + * Cold-start restore of the durable `webterm_auth` cookie into the live jar, EXACTLY ONCE. + * + * Once, because the durable write is asynchronous ([AuthCookieJar]'s persister hands off): a second + * hydration could overwrite a freshly re-authenticated in-memory cookie with the stale persisted one and + * silently put the app back on a dead credential. + * + * A failure is swallowed (warm-up must never fail on this) but does NOT consume the one shot, so the next + * warm-up retries. Worst case the user re-enters the token — never a crash, never a plaintext fallback. + */ +internal class AuthCookieHydrator( + private val store: () -> AuthCookieStore, + private val jar: () -> AuthCookieJar, +) { + private val hydrated = AtomicBoolean(false) + + /** Reads the store and restores each host's cookies under that host's own key. Call off `Main`. */ + internal suspend fun hydrateOnce() { + if (!hydrated.compareAndSet(false, true)) return + val records = try { + store().loadAll() + } catch (error: Throwable) { + hydrated.set(false) // not consumed — a later warm-up may succeed + if (error is CancellationException) throw error + return + } + val cookieJar = jar() + records.groupBy { it.hostKey }.forEach { (hostKey, forHost) -> + cookieJar.restore(hostKey, forHost.map { it.toSnapshot() }) + } + } +} + +/** + * `:transport-okhttp`'s jar DTO → `:host-registry`'s at-rest record, stamped with the isolation + * [hostKey]. The two modules must not depend on each other (`android/README.md`: dependencies only flow + * down), so `:app` owns this field-for-field mapping. `expiresAtEpochMillis` stays ABSOLUTE — persisting + * a `Max-Age` duration would restart the credential's lifetime on every restore. + */ +internal fun AuthCookieSnapshot.toRecord(hostKey: String): AuthCookieRecord = AuthCookieRecord( + hostKey = hostKey, + name = name, + value = value, + domain = domain, + path = path, + expiresAtEpochMillis = expiresAtEpochMillis, + secure = secure, + httpOnly = httpOnly, + hostOnly = hostOnly, +) + +/** The reverse mapping (cold-start hydration). `hostKey` is dropped — it is the jar's partition key. */ +internal fun AuthCookieRecord.toSnapshot(): AuthCookieSnapshot = AuthCookieSnapshot( + name = name, + value = value, + domain = domain, + path = path, + expiresAtEpochMillis = expiresAtEpochMillis, + secure = secure, + httpOnly = httpOnly, + hostOnly = hostOnly, +) diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt index a227af3..5b369f6 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt @@ -62,8 +62,10 @@ public class TerminalSessionControllerImpl( ) : TerminalSessionController by base { /** - * The forked Termux emulator for this session (no local process). The screen puts it on-screen via - * `attachView` and drives `updateSize`; A21 feeds it the remote output and routes its replies out. + * The forked Termux emulator for this session (no local process). The screen only puts it on-screen + * (`attachView`) / takes it off (`detachView`); the grid is driven from INSIDE `:terminal-view` + * (`RemoteTerminalHostView.onSizeChanged` → `TerminalResizeDriver` → `updateSize`), so nothing in `:app` + * computes cols×rows. A21's own two jobs are feeding it the remote output and routing its replies out. */ public val remoteSession: RemoteTerminalSession = remoteSessionFactory(::routeEmulatorMessage) { raw -> onSanitizedTitle(TitleSanitizer.sanitize(raw)) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt index 56aa575..c8f5bfd 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt @@ -31,17 +31,25 @@ import kotlin.math.ceil * JVM-tested via the [ThumbnailRasterizer] seam. */ public class CanvasThumbnailRasterizer( - private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX, + fontSizePx: Float = DEFAULT_FONT_SIZE_PX, typeface: Typeface = Typeface.MONOSPACE, + private val maxWidthPx: Int = ThumbnailBudget.MAX_WIDTH_PX, ) : ThumbnailRasterizer { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + /** + * METRICS ONLY — never used to draw. `ThumbnailPipeline` renders up to `MAX_CONCURRENT_RENDERS` (2) + * thumbnails at once on ONE rasterizer instance, so a shared draw `Paint` would be mutated + * (colour/bold/style per cell) by two coroutines concurrently — a data race producing wrong colours or + * a torn draw. Each [rasterize] therefore copies this template into its own local `Paint`. + */ + private val metricsPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.typeface = typeface textSize = fontSizePx } - private val cellWidth: Int = ceil(paint.measureText("X").toDouble()).toInt().coerceAtLeast(1) - private val cellHeight: Int = (paint.fontMetricsInt.descent - paint.fontMetricsInt.ascent).coerceAtLeast(1) - private val baselineOffset: Int = -paint.fontMetricsInt.ascent + private val cellWidth: Int = ceil(metricsPaint.measureText("X").toDouble()).toInt().coerceAtLeast(1) + private val cellHeight: Int = + (metricsPaint.fontMetricsInt.descent - metricsPaint.fontMetricsInt.ascent).coerceAtLeast(1) + private val baselineOffset: Int = -metricsPaint.fontMetricsInt.ascent override fun rasterize(preview: SessionPreview): Bitmap { val cols = preview.cols.coerceIn(1, MAX_COLS) @@ -50,15 +58,16 @@ public class CanvasThumbnailRasterizer( val bytes = preview.data.toByteArray(Charsets.UTF_8) emulator.append(bytes, bytes.size) - val bitmap = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) + val paint = Paint(metricsPaint) // per-render Paint — see [metricsPaint] + val full = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(full) val palette = emulator.mColors.mCurrentColors val defaultBg = palette[TextStyle.COLOR_INDEX_BACKGROUND] canvas.drawColor(defaultBg) for (row in 0 until rows) { - drawRow(canvas, emulator, row, cols, palette, defaultBg) + drawRow(canvas, paint, emulator, row, cols, palette, defaultBg) } - return bitmap + return downscaleToBudget(full) } override fun placeholder(): Bitmap { @@ -67,7 +76,29 @@ public class CanvasThumbnailRasterizer( return bitmap } - private fun drawRow(canvas: Canvas, emulator: TerminalEmulator, row: Int, cols: Int, palette: IntArray, defaultBg: Int) { + /** + * Shrink a full-resolution raster to the thumbnail budget. A 161×50 session rasterises to ~1127×700 = + * **3.1 MiB** ARGB_8888 — three of those alone exceed the pipeline's cache budget and would thrash the + * LRU on a list of sessions, re-rendering on every scroll. The card is ~96dp wide, so the full raster + * is downscaled once, here, and only the small bitmap is ever cached; the oversized source is recycled + * immediately. + */ + private fun downscaleToBudget(full: Bitmap): Bitmap { + val target = ThumbnailBudget.scaledSize(full.width, full.height, maxWidthPx) ?: return full + val scaled = Bitmap.createScaledBitmap(full, target.width, target.height, true) + if (scaled !== full) full.recycle() + return scaled + } + + private fun drawRow( + canvas: Canvas, + paint: Paint, + emulator: TerminalEmulator, + row: Int, + cols: Int, + palette: IntArray, + defaultBg: Int, + ) { val screen = emulator.screen val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)) val text = line.mText @@ -126,6 +157,33 @@ public class CanvasThumbnailRasterizer( } } +/** + * The pure raster-size budget for a preview thumbnail: how large a rasterised terminal screen may be kept. + * Split out of [CanvasThumbnailRasterizer] so the arithmetic is JVM-unit-testable (android.graphics is + * stubbed off-device), while the `Bitmap`/`Canvas` work stays device-QA. + */ +public object ThumbnailBudget { + /** + * Max cached thumbnail width (px). The row tile is ~96dp (≈288px at 3× density) and drawn with + * `ContentScale.Crop`, so 480px keeps it crisp on any density while capping one cached bitmap at a few + * hundred KiB instead of the multi-MiB full-resolution raster. + */ + public const val MAX_WIDTH_PX: Int = 480 + + /** A raster target size in px. */ + public data class RasterSize(val width: Int, val height: Int) + + /** + * The size [width]×[height] should be scaled to, or `null` when it already fits [maxWidth] (draw as + * rendered — no extra allocation). Aspect ratio is preserved and the height never collapses to 0. + */ + public fun scaledSize(width: Int, height: Int, maxWidth: Int = MAX_WIDTH_PX): RasterSize? { + if (width <= 0 || height <= 0 || width <= maxWidth) return null + val scaledHeight = (height.toLong() * maxWidth / width).toInt().coerceAtLeast(1) + return RasterSize(width = maxWidth, height = scaledHeight) + } +} + /** Off-screen emulator sink: the preview emulator produces no wire output and needs no delegates. */ private object NoOpOutput : TerminalOutput() { override fun write(data: ByteArray?, offset: Int, count: Int) = Unit diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt index cd81fa5..c6ed37f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt @@ -6,13 +6,17 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import wang.yaojia.webterm.api.models.LiveSessionInfo import wang.yaojia.webterm.api.models.SessionPreview import wang.yaojia.webterm.api.routes.ApiClient @@ -56,16 +60,21 @@ public class ThumbnailPipeline( private val renderGate = Semaphore(maxConcurrent) /** In-flight renders by key; guarded by [inFlightMutex]. A second same-key request shares the entry. */ - private val inFlight = HashMap>() + private val inFlight = HashMap>() private val inFlightMutex = Mutex() /** - * The cached (or freshly rendered) thumbnail for [session]. Returns the cached bitmap immediately when - * `(id, lastOutputAt)` is unchanged; otherwise fetches the preview over mTLS, rasterises off-screen, - * caches, and returns it. Never throws for a fetch/render failure — a placeholder is cached and - * returned instead (only cooperative cancellation of [scope] propagates). + * The cached (or freshly rendered) thumbnail for [session], or `null` when no bitmap at all could be + * produced. Returns the cached bitmap immediately when `(id, lastOutputAt)` is unchanged; otherwise + * fetches the preview over mTLS, rasterises off-screen, caches, and returns it. + * + * **Never throws for a fetch/render failure** — a fetch/raster failure caches the rasterizer's + * placeholder (no retry storm), and the residual case where even the placeholder cannot be allocated + * (a `Bitmap` OOM) degrades to `null` rather than an exception: the caller is a row's `LaunchedEffect`, + * so a throw would take down the composition. Only cancellation of the CALLER propagates; a render + * killed by [close] also surfaces as `null` (the caller is still alive — it just has no thumbnail). */ - public suspend fun thumbnail(session: LiveSessionInfo): Bitmap { + public suspend fun thumbnail(session: LiveSessionInfo): Bitmap? { val key = ThumbnailKey(session.id, session.lastOutputAt) // Fast path: an unchanged lastOutputAt ⇒ identical screen ⇒ never re-render (§6.7). cache.get(key)?.let { return it } @@ -76,7 +85,23 @@ public class ThumbnailPipeline( cache.get(key)?.let { return it } inFlight[key] ?: startRender(key).also { inFlight[key] = it } } - return deferred.await() + return try { + deferred.await() + } catch (cancel: CancellationException) { + // Distinguish "the pipeline was closed under us" (→ no thumbnail) from "OUR caller was + // cancelled" (→ propagate, so the collector tears down as structured concurrency requires). + if (currentCoroutineContext().isActive) null else throw cancel + } + } + + /** + * Release every cached bitmap that is not part of the CURRENT live list — killed/exited sessions and + * superseded `lastOutputAt` keys — so the cache holds only what the chooser can still display. The + * [LruCache] byte budget already bounds worst-case memory; this returns the memory promptly instead of + * waiting for eviction pressure, and keeps dead sessions from occupying the budget at all. + */ + public fun retainOnly(live: Collection) { + cache.retainOnly(live.mapTo(HashSet()) { ThumbnailKey(it.id, it.lastOutputAt) }) } /** Cancel all in-flight renders and release the pipeline scope (call from the owner's teardown). */ @@ -84,18 +109,28 @@ public class ThumbnailPipeline( scope.cancel() } - private fun startRender(key: ThumbnailKey): Deferred = scope.async { - val bitmap = renderGuarded(key.sessionId) - // Publish under the lock so a concurrent [thumbnail] re-check sees the result the instant this - // key stops being in-flight — success AND placeholder are cached identically (§6.7 no-retry-storm). - inFlightMutex.withLock { - cache.put(key, bitmap) - inFlight.remove(key) + private fun startRender(key: ThumbnailKey): Deferred = scope.async { + var rendered: Bitmap? = null + try { + rendered = renderGuarded(key.sessionId) + rendered + } finally { + // ALWAYS release the in-flight slot — including on cancellation (scope closed) and on an + // unexpected throw. A retained entry would poison the key: every later request for it would + // await a dead Deferred forever. NonCancellable so the cleanup still runs while cancelling. + withContext(NonCancellable) { + inFlightMutex.withLock { + // Publish under the same lock, so a concurrent [thumbnail] re-check sees the result the + // instant the key stops being in-flight. Success AND placeholder cache identically + // (§6.7 no-retry-storm); an unproducible bitmap caches nothing and may be retried. + rendered?.let { cache.put(key, it) } + inFlight.remove(key) + } + } } - bitmap } - private suspend fun renderGuarded(sessionId: UUID): Bitmap = + private suspend fun renderGuarded(sessionId: UUID): Bitmap? = try { renderGate.withPermit { val preview = previewSource.fetch(sessionId) @@ -105,8 +140,9 @@ public class ThumbnailPipeline( throw e // never swallow cooperative cancellation } catch (t: Throwable) { // Any fetch/render failure (network, oversized body, malformed escape) → cache a placeholder - // so the broken session isn't retried on every scroll frame (§6.7). - rasterizer.placeholder() + // so the broken session isn't retried on every scroll frame (§6.7). If even the placeholder + // cannot be allocated, give up with null — never throw at a UI collector. + runCatching { rasterizer.placeholder() }.getOrNull() } public companion object { @@ -118,6 +154,25 @@ public class ThumbnailPipeline( } } +/** + * Build the production preview pipeline for ONE host: the read-only [ApiPreviewSource] over that host's + * [ApiClient] (the shared mTLS `OkHttpClient`) feeding the [CanvasThumbnailRasterizer] off-screen painter, + * with the default LRU budget / `Semaphore(2)` cap / `Dispatchers.Default` render pool. + * + * This is the ONE construction point (`SessionsHome` wires the active host through it), so nothing else + * has to know which rasterizer or which cache the session chooser uses. [api] is a PROVIDER and is + * resolved lazily on the first fetch — inside the pipeline's own background dispatcher — so calling this + * from a composition does NO `OkHttpClient` / AndroidKeyStore / Tink work on `Main`. + * + * The owner MUST call [ThumbnailPipeline.close] when the surface goes away (the render scope outlives any + * single caller by design). + */ +public fun sessionThumbnailPipeline(api: () -> ApiClient): ThumbnailPipeline = + ThumbnailPipeline( + previewSource = ApiPreviewSource(api), + rasterizer = CanvasThumbnailRasterizer(), + ) + /** * Cache key: an unchanged `(sessionId, lastOutputAt)` pair means the server's last PTY output is * unchanged ⇒ the preview is byte-identical ⇒ the cached bitmap is reused verbatim (plan §6.7). A `null` @@ -144,10 +199,17 @@ public interface ThumbnailRasterizer { public fun placeholder(): Bitmap } -/** The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable. */ +/** + * The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable. + * Implementations MUST be thread-safe: [ThumbnailPipeline] reads it from an unsynchronised fast path and + * writes it from its render coroutines ([LruCache] is internally synchronised). + */ public interface BitmapCache { public fun get(key: ThumbnailKey): Bitmap? public fun put(key: ThumbnailKey, bitmap: Bitmap) + + /** Drop every entry whose key is not in [keys] (dead sessions / superseded `lastOutputAt`). */ + public fun retainOnly(keys: Set) } /** @@ -164,14 +226,29 @@ public class LruBitmapCache(maxBytes: Int) : BitmapCache { override fun put(key: ThumbnailKey, bitmap: Bitmap) { lru.put(key, bitmap) } + + /** `snapshot()` is a copy, so removing while iterating it is safe (and `remove` is synchronised). */ + override fun retainOnly(keys: Set) { + for (key in lru.snapshot().keys) { + if (key !in keys) lru.remove(key) + } + } } /** * Production [PreviewSource] over an [ApiClient] (which sends through the shared mTLS `HttpTransport`, so - * tunnel-host previews traverse nginx). Re-caps the body at 256 KiB client-side — defence-in-depth over - * the server's own cap (plan §6.7 / §4.2). + * tunnel-host previews traverse nginx). `GET /live-sessions/:id/preview` is a READ-ONLY route: it does NOT + * attach, register a client, or touch the session's idle clock. Re-caps the body at 256 KiB client-side — + * defence-in-depth over the server's own cap (plan §6.7 / §4.2). + * + * [api] is a PROVIDER, not a client: resolving it resolves the shared `HttpTransport`, which builds the one + * `OkHttpClient` and first-touches the AndroidKeyStore/Tink mTLS material. That must never happen on the + * composition (`Main`) thread, so the client is materialised lazily inside [fetch], which runs on the + * pipeline's own background dispatcher (plan §"off-`Main` warm-up"). */ -public class ApiPreviewSource(private val apiClient: ApiClient) : PreviewSource { +public class ApiPreviewSource(api: () -> ApiClient) : PreviewSource { + private val apiClient: ApiClient by lazy(api) + override suspend fun fetch(sessionId: UUID): SessionPreview = PreviewCap.cap(apiClient.preview(sessionId)) } diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt index 525168b..ef9c7c6 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt @@ -204,6 +204,109 @@ class QuickReplyTest { assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = false)) } + // ── 4. The palette presenter (the production state holder behind the chips) ──── + + @Test + fun `palette load publishes the stored chips`() = runTest { + val palette = QuickReplyPalette(InMemoryQuickReplyStore()) + assertTrue(palette.chips.value.isEmpty(), "nothing is published before load()") + + palette.load() + assertEquals(QuickReplyDefaults.chips, palette.chips.value) + } + + @Test + fun `palette CRUD republishes the store's new list every time`() = runTest { + val palette = QuickReplyPalette(InMemoryQuickReplyStore(newId = sequentialId())) + palette.load() + + palette.add("run tests") + assertEquals("run tests", palette.chips.value.last().text) + + palette.edit("qr-yes", "yes please") + assertEquals("yes please", palette.chips.value.single { it.id == "qr-yes" }.text) + + val addedId = palette.chips.value.last().id + palette.remove(addedId) + assertFalse(palette.chips.value.any { it.id == addedId }) + + val idsBeforeMove = palette.chips.value.map { it.id } + palette.move(0, idsBeforeMove.lastIndex) + assertEquals(idsBeforeMove.drop(1) + idsBeforeMove.first(), palette.chips.value.map { it.id }) + } + + @Test + fun `palette rejects a blank add without touching the published list`() = runTest { + val palette = QuickReplyPalette(InMemoryQuickReplyStore()) + palette.load() + val before = palette.chips.value + + palette.add(" ") + assertEquals(before, palette.chips.value) + } + + @Test + fun `an emptied palette stays empty (no default re-seed) and hides the row`() = runTest { + val palette = QuickReplyPalette(InMemoryQuickReplyStore()) + palette.load() + palette.chips.value.forEach { palette.remove(it.id) } + + assertTrue(palette.chips.value.isEmpty()) + assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = palette.chips.value.isNotEmpty())) + } + + // ── 5. The editor's one-field select / commit reducer ───────────────────────── + + @Test + fun `a fresh editor state adds`() { + val state = QuickReplyEditorState() + assertNull(state.selectedId) + assertEquals("", state.draft) + assertEquals(QuickReplyCommit.NONE, state.commitKind()) // blank draft → nothing to commit + } + + @Test + fun `a non-blank draft with no selection is an ADD`() { + assertEquals(QuickReplyCommit.ADD, QuickReplyEditorState(draft = "deploy").commitKind()) + } + + @Test + fun `selecting a chip loads its text and switches the commit to EDIT`() { + val state = QuickReplyEditorState().selecting(QuickReplyChip("qr-yes", "yes")) + assertEquals("qr-yes", state.selectedId) + assertEquals("yes", state.draft) + assertEquals(QuickReplyCommit.EDIT, state.commitKind()) + } + + @Test + fun `a blank draft never commits, selected or not`() { + assertEquals(QuickReplyCommit.NONE, QuickReplyEditorState(selectedId = "qr-yes", draft = " ").commitKind()) + assertEquals(QuickReplyCommit.NONE, QuickReplyEditorState(draft = "").commitKind()) + } + + @Test + fun `clearing drops the selection and the draft, and never mutates the previous state`() { + val selected = QuickReplyEditorState().selecting(QuickReplyChip("qr-no", "no")) + val cleared = selected.cleared() + + assertNull(cleared.selectedId) + assertEquals("", cleared.draft) + // The previous snapshot is untouched (immutability). + assertEquals("qr-no", selected.selectedId) + assertEquals("no", selected.draft) + } + + @Test + fun `the draft is carried verbatim into the commit (surrounding whitespace preserved)`() = runTest { + val state = QuickReplyEditorState(draft = " spaced ") + assertEquals(QuickReplyCommit.ADD, state.commitKind()) + + val palette = QuickReplyPalette(InMemoryQuickReplyStore(initial = emptyList(), newId = sequentialId())) + palette.load() + palette.add(state.draft) + assertEquals(" spaced ", palette.chips.value.single().text) + } + // ── Pure transforms never mutate the receiver ───────────────────────────────── @Test diff --git a/android/app/src/test/java/wang/yaojia/webterm/nav/SessionRowMenuTest.kt b/android/app/src/test/java/wang/yaojia/webterm/nav/SessionRowMenuTest.kt new file mode 100644 index 0000000..15c9002 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/nav/SessionRowMenuTest.kt @@ -0,0 +1,89 @@ +package wang.yaojia.webterm.nav + +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.api.models.LiveSessionInfo +import wang.yaojia.webterm.components.sessionRowMenuActions +import wang.yaojia.webterm.designsystem.DisplayStatus +import wang.yaojia.webterm.viewmodels.SessionRow +import java.util.UUID + +/** + * The pointer-context-menu ACTION SET for a session row (A26 — the large-screen right-click menu). + * + * The menu's *visibility* gate is [PointerMenuPolicy] (already covered by `LayoutPolicyTest`); this covers + * the other half — which entries a row offers and what each one does. Pure JVM: the entries are built by + * [sessionRowMenuActions], so the wiring is verified without a device (the pointer gesture itself is + * device-QA, plan §7). + * + * NOTE the "copy" entry iOS has is deliberately ABSENT: copy-out belongs to the terminal's own text + * selection `ActionMode`, and a list row has no selected text — so the menu must not advertise it. + * + * (This file sits in the `nav` test package because it covers the row-menu half of the adaptive shell + * wired here; the function itself lives beside [wang.yaojia.webterm.components.PointerContextMenu] and + * [wang.yaojia.webterm.components.ContextMenuAction], which is its cohesive home.) + */ +class SessionRowMenuTest { + + private fun row(cwd: String?): SessionRow = SessionRow( + info = LiveSessionInfo( + id = UUID.fromString("11111111-2222-4333-8444-555555555555"), + createdAt = 0L, + clientCount = 1, + exited = false, + cwd = cwd, + cols = 80, + rows = 24, + lastOutputAt = 1L, + ), + displayStatus = DisplayStatus.Working, + title = "web-terminal", + isUnread = false, + ) + + @Test + fun `a row with a cwd offers open, new-session-in-cwd and kill — and never copy`() { + val actions = sessionRowMenuActions(row("/srv/app"), onOpen = {}, onNewSessionInCwd = {}, onKill = {}) + + assertEquals(3, actions.size) + assertTrue(actions[0].label.contains("打开"), "the first entry opens the session: ${actions[0].label}") + assertTrue(actions[1].label.contains("开新会话"), "cwd spawn entry missing: ${actions[1].label}") + assertTrue(actions[2].label.contains("终止"), "kill entry missing: ${actions[2].label}") + assertFalse(actions.any { it.label.contains("复制") }, "copy is a documented gap — must not appear") + } + + @Test + fun `a row without a usable cwd drops the new-session entry`() { + assertEquals(2, sessionRowMenuActions(row(null), onOpen = {}, onNewSessionInCwd = {}, onKill = {}).size) + assertEquals(2, sessionRowMenuActions(row(" "), onOpen = {}, onNewSessionInCwd = {}, onKill = {}).size) + } + + @Test + fun `no spawn handler wired means no new-session entry, even with a cwd`() { + val actions = sessionRowMenuActions(row("/srv/app"), onOpen = {}, onNewSessionInCwd = null, onKill = {}) + assertEquals(2, actions.size) + assertFalse(actions.any { it.label.contains("开新会话") }) + } + + @Test + fun `each entry invokes its callback with the row's own id and cwd`() { + var opened: UUID? = null + var spawnedIn: String? = null + var killed: UUID? = null + val target = row("/home/dev/project") + + val actions = sessionRowMenuActions( + row = target, + onOpen = { opened = it }, + onNewSessionInCwd = { spawnedIn = it }, + onKill = { killed = it }, + ) + actions.forEach { it.onClick() } + + assertEquals(target.id, opened) + assertEquals("/home/dev/project", spawnedIn) + assertEquals(target.id, killed) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/screens/TerminalReclaimPolicyTest.kt b/android/app/src/test/java/wang/yaojia/webterm/screens/TerminalReclaimPolicyTest.kt new file mode 100644 index 0000000..46f808a --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/screens/TerminalReclaimPolicyTest.kt @@ -0,0 +1,67 @@ +package wang.yaojia.webterm.screens + +import androidx.lifecycle.Lifecycle +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.components.BannerModel +import wang.yaojia.webterm.session.FailureReason +import kotlin.time.Duration.Companion.seconds + +/** + * The §6.4 latest-writer-wins reclaim policy — the pure half of the device-switch resize path + * (plan §6.4 / A21). The Compose/lifecycle plumbing that feeds it is device-QA (§7); WHICH events + * must reach `SessionEngine.notifyForegrounded` is decided here, in JVM-testable data. + * + * The rule that makes this non-obvious: `:terminal-view`'s `TerminalResizeDriver` now owns pushing + * the measured grid on a layout change (and dedups sub-cell churn), so an extra engine-level + * re-assert on the SAME size-changed event would double every rotation's `resize` frame. The engine + * call therefore belongs to the events the view layer cannot see — becoming the active device again + * (resume / window-focus gain) — plus the disconnected case, where it is the connect-now nudge. + */ +class TerminalReclaimPolicyTest { + + // ── Lifecycle: only ON_RESUME means "this device is active again" ──────────────────────────── + + @Test + fun `ON_RESUME reclaims the shared PTY size`() { + assertTrue(TerminalReclaimPolicy.reclaimsOnLifecycle(Lifecycle.Event.ON_RESUME)) + } + + @Test + fun `no other lifecycle event reclaims`() { + Lifecycle.Event.values() + .filter { it != Lifecycle.Event.ON_RESUME } + .forEach { event -> + assertFalse(TerminalReclaimPolicy.reclaimsOnLifecycle(event), "$event must not reclaim") + } + } + + // ── Window focus: gain only (a blur must never resize — plan §6.4 removed the size-vote) ──── + + @Test + fun `window-focus GAIN reclaims and a blur does not`() { + assertTrue(TerminalReclaimPolicy.reclaimsOnWindowFocus(focused = true)) + assertFalse(TerminalReclaimPolicy.reclaimsOnWindowFocus(focused = false)) + } + + // ── View size change: nudge only while disconnected (the driver owns the connected case) ──── + + @Test + fun `a size change while connected does NOT re-assert (the view driver already pushed it)`() { + assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Hidden)) + } + + @Test + fun `a size change while connecting or reconnecting nudges connect-now`() { + assertTrue(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Connecting)) + assertTrue(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Reconnecting(2, 4.seconds))) + } + + @Test + fun `a size change on a terminal banner never nudges (no reconnect is owed)`() { + assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE))) + assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Exited(0, null))) + assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Exited(-1, "spawn failed"))) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt index 85716bf..5afb2d3 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt @@ -1,6 +1,7 @@ package wang.yaojia.webterm.viewmodels import wang.yaojia.webterm.api.models.CreateWorktreeResult +import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.GitLogResult import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PrStatus @@ -9,6 +10,7 @@ import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.PruneWorktreesResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult import wang.yaojia.webterm.api.models.UiPrefs +import wang.yaojia.webterm.api.models.WorktreeState /** * A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel, @@ -18,6 +20,8 @@ import wang.yaojia.webterm.api.models.UiPrefs */ class FakeWorktreeGateway( private val detail: ProjectDetail? = null, + private val worktreeStates: Map = emptyMap(), + private val fetchOutcome: GitWriteOutcome = GitWriteOutcome.Ok(FetchResult()), private val prResult: PrStatus? = null, private val prThrows: Boolean = false, private val logResult: GitLogResult? = null, @@ -29,6 +33,8 @@ class FakeWorktreeGateway( val createCalls = mutableListOf>() val removeCalls = mutableListOf>() val pruneCalls = mutableListOf() + val worktreeStateCalls = mutableListOf() + val fetchCalls = mutableListOf() var detailCalls = 0 private set @@ -51,6 +57,16 @@ class FakeWorktreeGateway( return logResult ?: throw NotImplementedError("no log configured") } + override suspend fun worktreeState(worktreePath: String): WorktreeState { + worktreeStateCalls += worktreePath + return worktreeStates[worktreePath] ?: throw RuntimeException("probe failed for $worktreePath") + } + + override suspend fun gitFetch(path: String): GitWriteOutcome { + fetchCalls += path + return fetchOutcome + } + override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome { createCalls += Triple(path, branch, base) return createOutcome diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GatePreviewTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GatePreviewTest.kt new file mode 100644 index 0000000..d9f7e7e --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GatePreviewTest.kt @@ -0,0 +1,192 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.session.Gate +import wang.yaojia.webterm.session.GateState +import wang.yaojia.webterm.session.SessionEvent +import wang.yaojia.webterm.wire.ApprovalPreview +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.GateKind +import wang.yaojia.webterm.wire.PreviewDiffFile +import wang.yaojia.webterm.wire.PreviewDiffHunk +import wang.yaojia.webterm.wire.PreviewDiffLine +import wang.yaojia.webterm.wiring.TerminalSessionController + +/** + * W1 approval preview — a remote one-tap approval must show WHAT it approves (the command or the + * diff), not just a tool name. The content is derived server-side from the hook `tool_input`, i.e. it + * is **attacker-influenced**: it renders strictly inert, and this test pins the client-side + * defence-in-depth sanitizer plus the rule that an ABSENT preview is a NORMAL state (the name-only + * gate stays fully decidable — a missing preview never blocks or breaks a gate). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GatePreviewTest { + + private class FakeController : TerminalSessionController { + private val channel = Channel(Channel.UNLIMITED) + val decisions = mutableListOf>() + override fun controlEvents(): Flow = channel.receiveAsFlow() + override val output: Flow = emptyFlow() + override fun start() = Unit + override fun sendInput(data: String) = Unit + override fun resize(cols: Int, rows: Int) = Unit + override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit + override fun decideGate(epoch: Int, message: ClientMessage) { decisions += epoch to message } + override fun close() = Unit + fun emit(event: SessionEvent) { channel.trySend(event) } + } + + // ── Absent preview is a NORMAL state ───────────────────────────────────────────────────────── + + @Test + fun `a gate with no preview stays fully decidable and shows no preview block`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit(Gate(GateState(kind = GateKind.TOOL, detail = "Bash", epoch = 1))) + + assertNotNull(vm.uiState.value.gate, "the gate itself is unaffected") + assertNull(vm.uiState.value.preview, "no preview → the name-only bar, not an error") + vm.decide(GateDecision.APPROVE, 1) + assertEquals(1, fake.decisions.size, "a preview-less gate is still approvable") + } + + @Test + fun `a held gate carries its preview onto the ui state`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit( + Gate( + GateState( + kind = GateKind.TOOL, + detail = "Bash", + epoch = 1, + preview = ApprovalPreview.Command("rm -rf build/"), + ), + ), + ) + + val preview = vm.uiState.value.preview + assertTrue(preview is ApprovalPreview.Command, "expected a command preview, got $preview") + assertEquals("rm -rf build/", (preview as ApprovalPreview.Command).text) + } + + @Test + fun `a lifted gate clears any preview with it`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit( + Gate( + GateState( + kind = GateKind.TOOL, + detail = "Bash", + epoch = 1, + preview = ApprovalPreview.Command("rm -rf build/"), + ), + ), + ) + assertNotNull(vm.uiState.value.preview) + fake.emit(Gate(null)) + + assertNull(vm.uiState.value.gate) + assertNull(vm.uiState.value.preview, "a stale preview must never outlive its gate") + } + + @Test + fun `a new gate replaces the previous gate's preview`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + fake.emit(Gate(gateWithCommand(epoch = 1))) + fake.emit(Gate(gateWithCommand(epoch = 2))) + + assertEquals("cmd for epoch 2", (vm.uiState.value.preview as ApprovalPreview.Command).text) + } + + private fun gateWithCommand(epoch: Int) = GateState( + kind = GateKind.TOOL, + detail = "Bash", + epoch = epoch, + preview = ApprovalPreview.Command("cmd for epoch $epoch"), + ) + + // ── Inert rendering (§8) ──────────────────────────────────────────────────────────────────── + + @Test + fun `command preview text keeps newlines and strips control and ANSI bytes`() { + val raw = "rm -rf build/\n\u001B[31mecho\u0007 boom\u0000" + + val safe = inertPreviewText(raw) + + assertTrue(safe.contains("\n"), "a shell command's line structure is meaningful") + assertFalse(safe.contains("\u001B"), "no ESC \u2014 preview text must never drive the terminal") + assertFalse(safe.contains("\u0007"), "no BEL") + assertFalse(safe.contains("\u0000"), "no NUL") + assertEquals("rm -rf build/\n[31mecho boom", safe) + } + + @Test + fun `command preview leaves ordinary text and markup characters literal`() { + val raw = "grep -n '' *.ts && echo \"a&b\"" + + assertEquals(raw, inertPreviewText(raw), "no escaping, no linkify — the UI renders it as text") + } + + @Test + fun `tabs survive but carriage returns do not`() { + assertEquals("a\tb", inertPreviewText("a\tb")) + assertEquals("ab", inertPreviewText("a\rb"), "a bare CR would rewrite the rendered line") + } + + @Test + fun `the display text of a command preview is the sanitized one`() { + val preview = ApprovalPreview.Command("echo \u001Bx", truncated = true) + + assertEquals("echo x", preview.inertText(), "what the UI paints") + assertTrue(preview.truncated) + } + + @Test + fun `a command preview with nothing legible left is not a preview at all`() { + assertNull(ApprovalPreview.Command(" \u0000 ").inertText()) + assertEquals("ls", ApprovalPreview.Command("ls").inertText()) + } + + @Test + fun `a diff preview carries its one synthetic file and truncation flag`() { + val file = PreviewDiffFile( + oldPath = "a.ts", + newPath = "a.ts", + status = "modified", + added = 1, + removed = 0, + binary = false, + hunks = listOf(PreviewDiffHunk("@@ -1 +1 @@", listOf(PreviewDiffLine("added", "+x")))), + ) + + val preview = ApprovalPreview.Diff(file, truncated = true) + + assertEquals("a.ts", preview.file.newPath) + assertEquals("+x", preview.file.hunks.single().lines.single().text) + assertTrue(preview.truncated) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GitSyncPanelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GitSyncPanelTest.kt new file mode 100644 index 0000000..e22498b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GitSyncPanelTest.kt @@ -0,0 +1,275 @@ +package wang.yaojia.webterm.viewmodels + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.CommitLogEntry +import wang.yaojia.webterm.api.models.GitLogResult +import wang.yaojia.webterm.api.models.SyncState +import wang.yaojia.webterm.api.models.WorktreeInfo + +/** + * w6 git panel — the pure presentation core (`syncBandOf` / `boundaryIndex` / `worktreeChips` / + * `headerDirtyChip`), ported from the shipped web behaviour in `public/projects.ts` + + * `public/git-log.ts` and the design in `docs/mockups/project-detail-git.html`. + * + * The load-bearing rule these tests exist for: **exactly one state may read as the green + * "all clear"** — `ahead == 0 && behind == 0` AND a fetch inside SyncState.FETCH_FRESH_WINDOW_MS. "No upstream" + * leaves ahead/behind undefined and MUST fall through to an explicit `no upstream`, never to green + * (docs/plans/w6-project-git-panel.md: "the single easiest bug to ship"). + */ +class GitSyncPanelTest { + + private val now = 1_800_000_000_000L + private val fresh = now - 60_000L // 1 min ago → inside the 1 h staleness window + private val ancient = now - (19L * 24 * 60 * 60 * 1000) // 19 days ago + + // ── The green path: exactly one state ──────────────────────────────────────────────────────── + + @Test + fun `ahead zero behind zero with a fresh fetch is the one green all-clear`() { + val band = syncBandOf( + SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = fresh), + nowMs = now, + ) + + val tracking = assertTracking(band) + assertFalse(tracking.isStale, "a 1-minute-old fetch is fresh") + assertTrue(tracking.isAllClear, "↑0 ↓0 + fresh fetch is the single green state") + } + + @Test + fun `ahead zero behind zero with a stale fetch is never green and marks the behind number`() { + val band = syncBandOf( + SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = ancient), + nowMs = now, + ) + + val tracking = assertTracking(band) + assertTrue(tracking.isStale, "a 19-day-old FETCH_HEAD is stale") + assertFalse(tracking.isAllClear, "a stale ↓0 is a guess, not a fact — never green") + } + + @Test + fun `never fetched is stale, not fresh`() { + val band = syncBandOf( + SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = null), + nowMs = now, + ) + + val tracking = assertTracking(band) + assertTrue(tracking.isStale) + assertFalse(tracking.isAllClear) + } + + /** THE fall-through this feature is most likely to get wrong (plan §"Design rule"). */ + @Test + fun `no upstream renders an explicit no-upstream state and never the green path`() { + val band = syncBandOf( + SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh), + nowMs = now, + ) + + assertEquals(SyncBand.NoUpstream, band, "a branch that tracks nothing has no ↑↓ to report") + assertFalse(band!!.isAllClear, "absent numbers must never read as 'in sync'") + } + + @Test + fun `a blank upstream string is treated as no upstream`() { + val band = syncBandOf(SyncState(upstream = " ", lastFetchMs = fresh), nowMs = now) + + assertEquals(SyncBand.NoUpstream, band) + } + + @Test + fun `only the tracking-in-sync state is ever all clear`() { + val allClear = listOf( + syncBandOf(SyncState(detached = true), nowMs = now), + syncBandOf(SyncState(upstream = null), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 1, behind = 0, lastFetchMs = fresh), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 3, lastFetchMs = fresh), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = ancient), nowMs = now), + syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = fresh), nowMs = now), + ).map { it?.isAllClear } + + assertEquals(listOf(false, false, false, false, false, true), allClear) + } + + // ── Degraded shapes ───────────────────────────────────────────────────────────────────────── + + @Test + fun `a detached HEAD shows the short sha, no branch, and disables fetch`() { + val band = syncBandOf(SyncState(detached = true, lastFetchMs = fresh), head = "1dbed54", nowMs = now) + + assertEquals(SyncBand.Detached("1dbed54"), band) + assertFalse(band!!.canFetch, "fetch is not available on a detached HEAD") + } + + @Test + fun `fetch stays available for tracking and no-upstream states`() { + assertTrue(syncBandOf(SyncState(upstream = null), nowMs = now)!!.canFetch) + assertTrue(syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now)!!.canFetch) + } + + @Test + fun `absent sync facts render no band at all (non-git project)`() { + assertNull(syncBandOf(null, nowMs = now)) + } + + @Test + fun `absent ahead and behind on a tracking branch count as zero`() { + val tracking = assertTracking( + syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now), + ) + + assertEquals(0, tracking.ahead) + assertEquals(0, tracking.behind) + assertTrue(tracking.isAllClear, "a tracking branch with no counts and a fresh fetch IS in sync") + } + + // ── Header dirty chip (● 3, not a bare dot) ───────────────────────────────────────────────── + + @Test + fun `a dirty count renders the number, not a bare dot`() { + assertEquals(GitPanelCopy.dirtyChip(3), headerDirtyChip(dirtyCount = 3, dirty = true)) + assertTrue(headerDirtyChip(dirtyCount = 3, dirty = true)!!.contains("3")) + } + + @Test + fun `dirty without a count falls back to the countless chip, and clean renders nothing`() { + assertEquals(GitPanelCopy.DIRTY_NO_COUNT, headerDirtyChip(dirtyCount = null, dirty = true)) + assertNull(headerDirtyChip(dirtyCount = 0, dirty = true), "0 changed files is not dirty") + assertNull(headerDirtyChip(dirtyCount = null, dirty = false)) + assertNull(headerDirtyChip(dirtyCount = null, dirty = null)) + } + + // ── Commit list: unpushed marking + ONE boundary ───────────────────────────────────────────── + + private fun log(vararg unpushed: Boolean, upstream: String? = null, truncated: Boolean = false) = + GitLogResult( + commits = unpushed.mapIndexed { i, flag -> + CommitLogEntry(hash = "hash$i", at = now - i * 1000L, subject = "s$i", unpushed = flag) + }, + truncated = truncated, + upstream = upstream, + ) + + @Test + fun `the boundary is drawn once, after the last unpushed commit`() { + val log = log(true, true, false, false, upstream = "origin/develop") + + assertEquals(1, log.boundaryIndex(), "one boundary, right after the last unpushed row") + assertEquals("origin/develop", log.boundaryLabel()) + assertEquals(2, log.unpushedCount) + } + + /** + * A merge of an older branch interleaves an unpushed commit BELOW a pushed one. The per-commit flag + * is authoritative — never the row index — and the single boundary sits after the LAST unpushed row + * (plan §TDD step 19). + */ + @Test + fun `an unpushed commit sorting below a pushed one still moves the boundary`() { + val log = log(true, false, true, false, upstream = "origin/develop") + + assertEquals(2, log.boundaryIndex()) + assertEquals(listOf(true, false, true, false), log.commits.map { it.isUnpushed() }) + } + + @Test + fun `no unpushed commits means no boundary`() { + val log = log(false, false, upstream = "origin/develop") + + assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex()) + assertEquals(0, log.unpushedCount) + } + + @Test + fun `without an upstream no boundary is drawn even when rows are marked`() { + val log = log(true, true, upstream = null) + + assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex(), "nothing to draw the boundary against") + assertNull(log.boundaryLabel()) + } + + @Test + fun `a blank upstream draws no boundary and no label`() { + val log = log(true, upstream = " ") + + assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex()) + assertNull(log.boundaryLabel(), "an empty label would claim a position it cannot name") + } + + @Test + fun `an absent unpushed flag reads as pushed, never the other way round`() { + val commit = CommitLogEntry(hash = "h", at = now, subject = "s", unpushed = null) + + assertFalse(commit.isUnpushed(), "unknown must not be marked; only an explicit true counts") + } + + // ── Worktree section ──────────────────────────────────────────────────────────────────────── + + @Test + fun `the worktree section always reads Worktrees with its count, even at one`() { + assertEquals("Worktrees (1)", GitPanelCopy.worktreesTitle(1)) + assertEquals("Worktrees (2)", GitPanelCopy.worktreesTitle(2)) + assertEquals("Worktrees (0)", GitPanelCopy.worktreesTitle(0)) + } + + private val mainWorktree = WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true) + + @Test + fun `a worktree with no upstream shows no-upstream and never a green chip`() { + val chips = worktreeChips( + WorktreeInfo(path = "/repo/.claude/worktrees/x", branch = "worktree-x"), + WorktreeRowState(sync = SyncState(upstream = null, lastFetchMs = fresh)), + ) + + assertTrue(chips.any { it.label == GitPanelCopy.NO_UPSTREAM_CHIP }) + assertFalse(chips.any { it.tone == GitChipTone.OK }, "a worktree row has no green all-clear chip") + } + + @Test + fun `worktree chips report main current locked prunable then sync then dirty`() { + val chips = worktreeChips( + mainWorktree.copy(locked = true, prunable = true), + WorktreeRowState(sync = SyncState(upstream = "origin/develop", ahead = 9, lastFetchMs = fresh), dirtyCount = 3), + ) + + assertEquals( + listOf( + GitPanelCopy.MAIN_CHIP, + GitPanelCopy.CURRENT_CHIP, + GitPanelCopy.LOCKED_CHIP, + GitPanelCopy.PRUNABLE_CHIP, + GitPanelCopy.aheadChip(9), + GitPanelCopy.dirtyCountChip(3), + ), + chips.map { it.label }, + ) + } + + @Test + fun `an unprobed worktree row shows no sync chip at all rather than a guess`() { + val chips = worktreeChips(mainWorktree, WorktreeRowState()) + + assertEquals(listOf(GitPanelCopy.MAIN_CHIP, GitPanelCopy.CURRENT_CHIP), chips.map { it.label }) + } + + @Test + fun `a detached worktree row says detached`() { + val chips = worktreeChips( + WorktreeInfo(path = "/repo/wt", branch = null, head = "1dbed54"), + WorktreeRowState(sync = SyncState(detached = true)), + ) + + assertEquals(listOf(GitPanelCopy.DETACHED_CHIP_SHORT), chips.map { it.label }) + } + + private fun assertTracking(band: SyncBand?): SyncBand.Tracking { + assertTrue(band is SyncBand.Tracking, "expected a tracking band, got $band") + return band as SyncBand.Tracking + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailFetchTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailFetchTest.kt new file mode 100644 index 0000000..c449946 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailFetchTest.kt @@ -0,0 +1,177 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +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.Test +import wang.yaojia.webterm.api.models.FetchResult +import wang.yaojia.webterm.api.models.GitWriteOutcome +import wang.yaojia.webterm.api.models.GitLogResult +import wang.yaojia.webterm.api.models.PrAvailability +import wang.yaojia.webterm.api.models.PrStatus +import wang.yaojia.webterm.api.models.ProjectDetail +import wang.yaojia.webterm.api.models.WorktreeInfo +import wang.yaojia.webterm.api.models.WorktreeState + +/** + * w6/G2+G3 — the Fetch action on the project-detail sync band. + * + * `git fetch` only moves remote-tracking refs (no pull, no merge), so it is the ONE write the panel + * offers. The rules pinned here: one in-flight fetch at a time (a second tap is dropped, never + * queued), a success re-loads the detail so `behind`/`lastFetchMs` actually move, and a FAILURE + * changes nothing — `lastFetchMs` stays where it was so the `stale` flag stays ON (never silently + * mark the data fresh). + */ +class ProjectDetailFetchTest { + + private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "develop") + + private fun okFetch(lastFetchMs: Long?): GitWriteOutcome = + GitWriteOutcome.Ok(FetchResult(lastFetchMs)) + + private fun vmWith( + fetchRemote: (suspend () -> GitWriteOutcome)?, + ): Pair { + val detailCalls = intArrayOf(0) + val vm = ProjectDetailViewModel( + path = "/repo", + fetch = { detailCalls[0]++; detail }, + fetchRemote = fetchRemote, + ) + return vm to detailCalls + } + + @Test + fun `no fetch seam wired means the button is not offered`() { + val (vm, _) = vmWith(null) + + assertFalse(vm.canFetch, "without a fetch gateway the band must not offer the action") + assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value) + } + + @Test + fun `a successful fetch reports the new lastFetchMs and re-loads the detail`() = runTest { + val (vm, detailCalls) = vmWith { okFetch(42L) } + vm.load() + val callsAfterLoad = detailCalls[0] + + vm.fetchUpstream() + + assertTrue(vm.canFetch) + assertEquals(ProjectDetailViewModel.FetchPhase.Done(42L), vm.fetchPhase.value) + assertEquals(callsAfterLoad + 1, detailCalls[0], "a fetch refreshes ahead/behind") + } + + @Test + fun `a failing fetch surfaces a short message and does NOT re-load or mark data fresh`() = runTest { + val (vm, detailCalls) = vmWith { throw RuntimeException("offline") } + vm.load() + val callsAfterLoad = detailCalls[0] + + vm.fetchUpstream() + + val phase = vm.fetchPhase.value + assertTrue(phase is ProjectDetailViewModel.FetchPhase.Failed, "expected Failed, got $phase") + assertEquals(GitPanelCopy.FETCH_FAILED, (phase as ProjectDetailViewModel.FetchPhase.Failed).message) + assertEquals(callsAfterLoad, detailCalls[0], "a failed fetch must not claim fresh data") + } + + @Test + fun `a second fetch while one is in flight is dropped, not queued`() = runTest { + val gate = CompletableDeferred>() + var invocations = 0 + val vm = ProjectDetailViewModel( + path = "/repo", + fetch = { detail }, + fetchRemote = { invocations++; gate.await() }, + ) + + val first = launch { vm.fetchUpstream() } + // Let the first call reach the suspension point, then tap again. + kotlinx.coroutines.yield() + assertEquals(ProjectDetailViewModel.FetchPhase.Working, vm.fetchPhase.value) + vm.fetchUpstream() + gate.complete(okFetch(7L)) + first.join() + + assertEquals(1, invocations, "the in-flight fetch is not re-entered") + assertEquals(ProjectDetailViewModel.FetchPhase.Done(7L), vm.fetchPhase.value) + } + + @Test + fun `the fetch banner is dismissable back to idle`() = runTest { + val (vm, _) = vmWith { okFetch(1L) } + vm.fetchUpstream() + + vm.clearFetchBanner() + + assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value) + } + + @Test + fun `a rejected fetch surfaces the server's own safe message verbatim`() = runTest { + val (vm, detailCalls) = vmWith { GitWriteOutcome.Rejected(status = 400, message = "两个 remote,无法确定目标") } + vm.load() + val callsAfterLoad = detailCalls[0] + + vm.fetchUpstream() + + assertEquals( + ProjectDetailViewModel.FetchPhase.Failed("两个 remote,无法确定目标"), + vm.fetchPhase.value, + ) + assertEquals(callsAfterLoad, detailCalls[0], "a rejected fetch must not claim fresh data") + } + + @Test + fun `a rate-limited fetch says so and does not retry`() = runTest { + val (vm, _) = vmWith { GitWriteOutcome.RateLimited } + + vm.fetchUpstream() + + assertEquals( + ProjectDetailViewModel.FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED), + vm.fetchPhase.value, + ) + } + + @Test + fun `a fetch failure never fails the detail load`() = runTest { + val (vm, _) = vmWith { throw RuntimeException("offline") } + + vm.load() + vm.fetchUpstream() + + assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "isolation: detail stays loaded") + } + + // ── w6/G7: the per-worktree probe ──────────────────────────────────────────────────────────── + + @Test + fun `probing skips the current worktree and isolates a failing row`() = runTest { + val worktrees = listOf( + WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true), + WorktreeInfo(path = "/repo/wt-a", branch = "worktree-a"), + WorktreeInfo(path = "/repo/wt-b", branch = "worktree-b"), + ) + val gateway = FakeWorktreeGateway( + detail = detail.copy(worktrees = worktrees), + prResult = PrStatus(availability = PrAvailability.NO_PR), + logResult = GitLogResult(), + // wt-b is missing on purpose: its probe throws, and must cost only its own row. + worktreeStates = mapOf("/repo/wt-a" to WorktreeState(path = "/repo/wt-a", dirtyCount = 2)), + ) + val vm = ProjectDetailViewModel.forGateway(gateway, "/repo") + vm.load() + + vm.probeWorktrees() + + assertEquals(listOf("/repo/wt-a", "/repo/wt-b"), gateway.worktreeStateCalls, "the current row is free") + assertEquals(setOf("/repo/wt-a"), vm.worktreeStates.value.keys) + assertEquals(2, vm.worktreeStates.value.getValue("/repo/wt-a").dirtyCount) + assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "a failed probe never fails the panel") + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt index 67d7f90..0f43bf8 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt @@ -204,6 +204,8 @@ class ProjectsViewModelTest { override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError() override suspend fun projectPr(path: String) = throw NotImplementedError() override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError() + override suspend fun worktreeState(worktreePath: String) = throw NotImplementedError() + override suspend fun gitFetch(path: String) = throw NotImplementedError() override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError() override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError() override suspend fun pruneWorktrees(path: String) = throw NotImplementedError() diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt new file mode 100644 index 0000000..91ac004 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt @@ -0,0 +1,183 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import wang.yaojia.webterm.wire.HttpResponse +import wang.yaojia.webterm.wire.HttpTransport +import java.io.IOException + +/** + * [AccessTokenGateway] — the `WEBTERM_TOKEN` half of pairing, verified against the server contract in + * `src/http/auth.ts` + `src/server.ts` (`POST /auth`: urlencoded `token` field; XHR ⇒ 204 valid / 401 + * invalid / 429 over the 10-per-minute limit; the global gate answers an unauthed read with 401). + * + * The credential-hygiene assertions are as load-bearing as the status mapping: the token is a SHELL + * credential, so it must appear ONLY in the request body and never in a URL, a header, a returned + * outcome, or any `toString()` that could reach a log or a crash report. + */ +@DisplayName("AccessTokenGateway — POST /auth + 401 gate detection") +class AccessTokenGatewayTest { + + private companion object { + /** A token using the full server-side charset (`[A-Za-z0-9._~+/=-]`) — `+`, `/`, `=` are the + * form-encoding traps: a raw `+` in an urlencoded body decodes to a SPACE server-side. */ + const val TOKEN = "ab+cd/ef=gh-ij._~" + const val BASE = "http://10.0.0.5:3000" + } + + private class RecordingTransport( + private val handler: (HttpRequest) -> HttpResponse, + ) : HttpTransport { + val requests = mutableListOf() + + override suspend fun send(request: HttpRequest): HttpResponse { + requests += request + return handler(request) + } + } + + private class ThrowingTransport(private val error: Throwable) : HttpTransport { + override suspend fun send(request: HttpRequest): HttpResponse = throw error + } + + private fun endpoint(url: String = BASE): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" } + + private fun status(code: Int): (HttpRequest) -> HttpResponse = + { HttpResponse(status = code, body = ByteArray(0)) } + + private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport } + + // ── Gate detection: is this host token-gated, or is it just not a web-terminal? ────────────────── + + @Test + fun `a 401 on the unauthenticated read means the host is token-gated`() = runTest { + val transport = RecordingTransport(status(401)) + + assertTrue(gateway(transport).requiresAccessToken(endpoint())) + assertEquals("$BASE/live-sessions", transport.requests.single().url) + assertEquals(HttpMethod.GET, transport.requests.single().method) + // The read is Origin-free (plan §4.3: Origin only on guarded routes). + assertTrue(transport.requests.single().headers.keys.none { it.equals("Origin", ignoreCase = true) }) + } + + @Test + fun `a 200 host is not token-gated`() = runTest { + assertFalse(gateway(RecordingTransport(status(200))).requiresAccessToken(endpoint())) + } + + @Test + fun `some other 4xx or 5xx is not read as the token gate`() = runTest { + for (code in listOf(403, 404, 418, 500, 503)) { + assertFalse( + gateway(RecordingTransport(status(code))).requiresAccessToken(endpoint()), + "HTTP $code must not be mistaken for the WEBTERM_TOKEN gate", + ) + } + } + + @Test + fun `an unreachable host is not reported as token-gated`() = runTest { + // "Nothing answered" must stay distinguishable from "answered 401" — never prompt for a + // credential because the host is down. + assertFalse(gateway(ThrowingTransport(IOException("connect refused"))).requiresAccessToken(endpoint())) + } + + // ── POST /auth status mapping ──────────────────────────────────────────────────────────────────── + + @Test + fun `204 accepts the token`() = runTest { + assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN)) + } + + @Test + fun `401 is a wrong token, distinct from an unreachable host`() = runTest { + assertEquals(TokenSubmission.Rejected, gateway(RecordingTransport(status(401))).submit(endpoint(), TOKEN)) + + val unreachable = gateway(ThrowingTransport(IOException("no route"))).submit(endpoint(), TOKEN) + assertInstanceOf(TokenSubmission.Unreachable::class.java, unreachable) + } + + @Test + fun `429 is surfaced as rate-limited and never auto-retried`() = runTest { + val transport = RecordingTransport(status(429)) + + assertEquals(TokenSubmission.RateLimited, gateway(transport).submit(endpoint(), TOKEN)) + assertEquals(1, transport.requests.size, "a 429 must not be retried inside the gateway") + } + + @Test + fun `an unexpected status is reported as unreachable, not as a wrong token`() = runTest { + val outcome = gateway(RecordingTransport(status(500))).submit(endpoint(), TOKEN) + + assertInstanceOf(TokenSubmission.Unreachable::class.java, outcome) + assertEquals("HTTP 500", (outcome as TokenSubmission.Unreachable).reason) + } + + // ── Request shape (the server contract) ────────────────────────────────────────────────────────── + + @Test + fun `the token is posted as an urlencoded form field with every reserved byte escaped`() = runTest { + val transport = RecordingTransport(status(204)) + + gateway(transport).submit(endpoint(), TOKEN) + + val request = transport.requests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/auth", request.url) + assertEquals( + "application/x-www-form-urlencoded", + request.headers.entries.single { it.key.equals("Content-Type", ignoreCase = true) }.value, + ) + // `+` MUST NOT survive raw (Express's qs decodes it to a space) and `/`/`=` would split the field. + assertEquals("token=ab%2Bcd%2Fef%3Dgh-ij._~", request.body?.decodeToString()) + } + + @Test + fun `no Accept text-html header is sent, so the server answers 204 or 401 instead of redirecting`() = + runTest { + val transport = RecordingTransport(status(204)) + + gateway(transport).submit(endpoint(), TOKEN) + + val accept = transport.requests.single().headers.entries + .firstOrNull { it.key.equals("Accept", ignoreCase = true) }?.value.orEmpty() + assertFalse(accept.contains("text/html"), "an HTML Accept makes /auth answer 302, not 204/401") + } + + // ── Credential hygiene ────────────────────────────────────────────────────────────────────────── + + @Test + fun `the token never reaches the URL, a header, the outcome or any toString`() = runTest { + val transport = RecordingTransport(status(401)) + + val outcome = gateway(transport).submit(endpoint(), TOKEN) + + val request = transport.requests.single() + assertFalse(request.url.contains(TOKEN), "never put the credential in a URL (logs / Referer)") + assertFalse(request.headers.toString().contains(TOKEN)) + assertFalse(outcome.toString().contains(TOKEN)) + // The data class renders `body=[B@…` — the bytes are never stringified. + assertFalse(request.toString().contains(TOKEN)) + } + + @Test + fun `a transport failure reports only the error type, never the message or the token`() = runTest { + // An exception message is attacker/host-influenced and could echo a request; carry the type only. + val error = IOException("failed posting token=$TOKEN") + val outcome = gateway(ThrowingTransport(error)).submit(endpoint(), TOKEN) + + val reason = (outcome as TokenSubmission.Unreachable).reason + assertEquals("IOException", reason) + assertFalse(outcome.toString().contains(TOKEN)) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieHydratorTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieHydratorTest.kt new file mode 100644 index 0000000..7d1fe7b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieHydratorTest.kt @@ -0,0 +1,174 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +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 +import wang.yaojia.webterm.hostregistry.AuthCookieId +import wang.yaojia.webterm.hostregistry.AuthCookieRecord +import wang.yaojia.webterm.hostregistry.AuthCookieStore +import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore +import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME +import wang.yaojia.webterm.transport.AuthCookieJar + +/** + * [AuthCookieHydrator] — the cold-start half of the `WEBTERM_TOKEN` gate: without it the durable + * `webterm_auth` cookie never reaches the live [AuthCookieJar], so a paired token-gated host would ask + * for the token again on every launch. + * + * Two properties are security-load-bearing and asserted directly: + * - **per-host isolation** — a record filed under host A must never be presented to host B; + * - **hydrate exactly once** — a second hydration could overwrite a FRESHER in-memory cookie with the + * stale persisted one (the durable write is asynchronous), silently reverting to a dead credential. + */ +@DisplayName("AuthCookieHydrator — cold-start restore of the webterm_auth cookie") +class AuthCookieHydratorTest { + + private companion object { + const val HOST_A = "http://10.0.0.5:3000" + const val HOST_B = "http://10.0.0.9:3000" + const val TOKEN = "persisted-shell-credential" + const val FAR_FUTURE = 4_000_000_000_000L // ~2096 + } + + private fun record( + hostKey: String, + host: String, + value: String = TOKEN, + expiresAt: Long = FAR_FUTURE, + ) = AuthCookieRecord( + hostKey = hostKey, + name = AUTH_COOKIE_NAME, + value = value, + domain = host, + path = "/", + expiresAtEpochMillis = expiresAt, + secure = false, + httpOnly = true, + hostOnly = true, + ) + + private fun hydrator(store: AuthCookieStore, jar: AuthCookieJar) = + AuthCookieHydrator(store = { store }, jar = { jar }) + + private fun AuthCookieJar.valuesFor(baseUrl: String): List = + loadForRequest("$baseUrl/live-sessions".toHttpUrl()).map { it.value } + + @Test + fun `a persisted cookie is restored onto its own host and nowhere else`() = runTest { + val store = InMemoryAuthCookieStore( + listOf(record(HOST_A, "10.0.0.5"), record(HOST_B, "10.0.0.9", value = "other-host-token")), + ) + val jar = AuthCookieJar() + + hydrator(store, jar).hydrateOnce() + + assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A)) + assertEquals(listOf("other-host-token"), jar.valuesFor(HOST_B)) + } + + @Test + fun `an expired record is not resurrected`() = runTest { + val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5", expiresAt = 1L))) + val jar = AuthCookieJar() + + hydrator(store, jar).hydrateOnce() + + assertTrue(jar.valuesFor(HOST_A).isEmpty()) + } + + @Test + fun `hydration happens exactly once, so a fresher in-memory cookie is never clobbered`() = runTest { + val store = CountingStore(listOf(record(HOST_A, "10.0.0.5", value = "stale"))) + val jar = AuthCookieJar() + val hydrator = hydrator(store, jar) + + hydrator.hydrateOnce() + assertEquals(listOf("stale"), jar.valuesFor(HOST_A)) + + // A live re-auth replaces the credential in memory (its durable write is async and may lag). + jar.saveFromResponse( + "$HOST_A/auth".toHttpUrl(), + listOf( + okhttp3.Cookie.Builder() + .name(AUTH_COOKIE_NAME) + .value("fresh") + .expiresAt(FAR_FUTURE) + .path("/") + .hostOnlyDomain("10.0.0.5") + .build(), + ), + ) + + hydrator.hydrateOnce() + + assertEquals(1, store.loads, "a second warm-up must not re-read the store") + assertEquals(listOf("fresh"), jar.valuesFor(HOST_A)) + } + + @Test + fun `a failing store never breaks warm-up and is retried on the next one`() = runTest { + val store = FailingThenWorkingStore(listOf(record(HOST_A, "10.0.0.5"))) + val jar = AuthCookieJar() + val hydrator = hydrator(store, jar) + + hydrator.hydrateOnce() // throws inside → swallowed + assertTrue(jar.valuesFor(HOST_A).isEmpty()) + + hydrator.hydrateOnce() // the failure did not consume the one-shot + assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A)) + } + + @Test + fun `the restored credential is not exposed by the jar or record toString`() = runTest { + val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5"))) + val jar = AuthCookieJar() + + hydrator(store, jar).hydrateOnce() + + assertFalse(jar.toString().contains(TOKEN)) + assertFalse(store.loadAll().toString().contains(TOKEN)) + } + + // ── Doubles ───────────────────────────────────────────────────────────────────────────────────── + + private class CountingStore(initial: List) : AuthCookieStore { + private val delegate = InMemoryAuthCookieStore(initial) + var loads: Int = 0 + + override suspend fun loadAll(): List { + loads++ + return delegate.loadAll() + } + + override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey) + override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record) + override suspend fun remove(id: AuthCookieId) = delegate.remove(id) + override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey) + override suspend fun replaceHost(hostKey: String, records: List) = + delegate.replaceHost(hostKey, records) + } + + private class FailingThenWorkingStore(initial: List) : AuthCookieStore { + private val delegate = InMemoryAuthCookieStore(initial) + private var failed = false + + override suspend fun loadAll(): List { + if (!failed) { + failed = true + error("keystore unavailable") + } + return delegate.loadAll() + } + + override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey) + override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record) + override suspend fun remove(id: AuthCookieId) = delegate.remove(id) + override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey) + override suspend fun replaceHost(hostKey: String, records: List) = + delegate.replaceHost(hostKey, records) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieMappingTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieMappingTest.kt new file mode 100644 index 0000000..6d7310f --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/AuthCookieMappingTest.kt @@ -0,0 +1,62 @@ +package wang.yaojia.webterm.wiring + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME +import wang.yaojia.webterm.transport.AuthCookieSnapshot + +/** + * The `:app`-owned bridge between `:transport-okhttp`'s [AuthCookieSnapshot] (what the live cookie jar + * publishes) and `:host-registry`'s `AuthCookieRecord` (what is encrypted at rest). Neither module may + * depend on the other (`android/README.md`: dependencies only flow down), so this 2-function mapping is + * the seam — and a field dropped here is a silently broken credential, hence the field-for-field test. + */ +@DisplayName("AuthCookie snapshot ⇄ record mapping") +class AuthCookieMappingTest { + + private companion object { + const val HOST_KEY = "https://star.terminal.yaojia.wang:443" + const val TOKEN = "shell-credential" + } + + private fun snapshot() = AuthCookieSnapshot( + name = AUTH_COOKIE_NAME, + value = TOKEN, + domain = "star.terminal.yaojia.wang", + path = "/", + expiresAtEpochMillis = 4_000_000_000_000L, + secure = true, + httpOnly = true, + hostOnly = true, + ) + + @Test + fun `every field survives the round trip, with the host key stamped on the record`() { + val original = snapshot() + + val record = original.toRecord(HOST_KEY) + + assertEquals(HOST_KEY, record.hostKey) + assertEquals(original.name, record.name) + assertEquals(original.value, record.value) + assertEquals(original.domain, record.domain) + assertEquals(original.path, record.path) + // ABSOLUTE wall-clock expiry, never a Max-Age duration (which would restart on every restore). + assertEquals(original.expiresAtEpochMillis, record.expiresAtEpochMillis) + assertEquals(original.secure, record.secure) + assertEquals(original.httpOnly, record.httpOnly) + assertEquals(original.hostOnly, record.hostOnly) + + assertEquals(original, record.toSnapshot()) + } + + @Test + fun `neither side renders the credential in toString`() { + val record = snapshot().toRecord(HOST_KEY) + + assertFalse(record.toString().contains(TOKEN)) + assertFalse(record.toSnapshot().toString().contains(TOKEN)) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt new file mode 100644 index 0000000..7efb297 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt @@ -0,0 +1,303 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.pairing.PairingError +import wang.yaojia.webterm.api.pairing.PairingProbeResult +import wang.yaojia.webterm.hostregistry.InMemoryHostStore +import wang.yaojia.webterm.viewmodels.PairingProber +import wang.yaojia.webterm.viewmodels.PairingUiState +import wang.yaojia.webterm.viewmodels.PairingViewModel +import wang.yaojia.webterm.viewmodels.TokenError +import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * The `WEBTERM_TOKEN` acquisition flow in [PairingViewModel]: prompt → submit → success / wrong / 429. + * + * Lives in the `wiring` test package rather than next to `PairingViewModelTest` because the + * `app/src/test` `viewmodels` package belongs to another agent's file-ownership lane in this run; the + * subject under test is the (public) pairing state machine either way. + * + * Why the flow exists: when a host runs with `WEBTERM_TOKEN` set, `GET /live-sessions` answers **401** + * before anything else can happen (`src/server.ts` `authGate`). The frozen probe taxonomy can only + * report that as `HttpOkButNotWebTerminal` ("wrong port?"), which is actively misleading and leaves a + * token-gated host impossible to pair. So a failed probe asks the prober whether the host is gated, and + * the answer routes to a token prompt instead of the dead-end failure card. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@DisplayName("Pairing — WEBTERM_TOKEN acquisition") +class PairingTokenFlowTest { + + private companion object { + const val TOKEN = "s3cret-shell-token" + const val LAN = "http://10.0.0.5:3000" + const val TUNNEL = "https://star.terminal.yaojia.wang" + } + + /** Records every probe AND every token submission so "no retry / no re-probe" is directly assertable. */ + private class FakeProber( + var probeResult: (HostEndpoint) -> PairingProbeResult = { PairingProbeResult.Success(it) }, + var gated: Boolean = false, + var submission: (String) -> TokenSubmission = { TokenSubmission.Accepted }, + ) : PairingProber { + val probes = mutableListOf() + val submitted = mutableListOf() + + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult { + probes += endpoint + return probeResult(endpoint) + } + + override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = gated + + override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission { + submitted += token + return submission(token) + } + } + + private fun endpoint(url: String): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" } + + private fun gatedThenOpen(prober: FakeProber): (HostEndpoint) -> PairingProbeResult = { ep -> + // Before the token: the gate 401s the reachability read, which the probe can only classify as + // "not a web-terminal". After the token is accepted the same probe succeeds. + if (prober.gated) PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) + else PairingProbeResult.Success(ep) + } + + private fun newVm( + prober: PairingProber, + hasCert: () -> Boolean = { false }, + store: InMemoryHostStore = InMemoryHostStore(), + ) = PairingViewModel( + hostStore = store, + prober = prober, + hasDeviceCert = { hasCert() }, + newId = { "fixed-id" }, + ) + + // ── Prompt ────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a token-gated host asks for the token instead of showing the wrong-port failure`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + val state = vm.uiState.value + assertInstanceOf(PairingUiState.TokenRequired::class.java, state) + assertEquals(endpoint(LAN), (state as PairingUiState.TokenRequired).endpoint) + assertEquals(null, state.error, "the first prompt is not an error") + } + + @Test + fun `an ungated host still shows the wrong-port failure`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber( + probeResult = { PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) }, + gated = false, + ) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value) + } + + @Test + fun `a non-gate failure is never re-interpreted as a token prompt`() = runTest(UnconfinedTestDispatcher()) { + // `gated` is true here, but the failure is a timeout — only the ambiguous + // HttpOkButNotWebTerminal case is allowed to become a token prompt. + val prober = FakeProber(probeResult = { PairingProbeResult.Failure(PairingError.Timeout) }, gated = true) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + val state = vm.uiState.value + assertInstanceOf(PairingUiState.Failed::class.java, state) + assertEquals(PairingError.Timeout, (state as PairingUiState.Failed).error) + } + + // ── Submit ────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `submitting the right token continues pairing and persists the host`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + // Accepting the token means the cookie jar now holds the credential → the gate opens. + prober.submission = { prober.gated = false; TokenSubmission.Accepted } + val store = InMemoryHostStore() + val vm = newVm(prober, store = store) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertEquals(listOf(TOKEN), prober.submitted) + assertEquals(2, prober.probes.size, "an accepted token re-runs the probe") + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + assertEquals(LAN, store.loadAll().single().endpoint.baseUrl) + } + + @Test + fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.Rejected } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken("wrong") + + val state = vm.uiState.value + assertInstanceOf(PairingUiState.TokenRequired::class.java, state) + assertEquals(TokenError.INVALID, (state as PairingUiState.TokenRequired).error) + assertEquals(1, prober.probes.size, "a rejected token must not re-probe") + } + + @Test + fun `a 429 is surfaced honestly and never auto-retried`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.RateLimited } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertEquals(TokenError.RATE_LIMITED, (vm.uiState.value as PairingUiState.TokenRequired).error) + assertEquals(1, prober.submitted.size, "rate-limited must not resubmit by itself") + assertEquals(1, prober.probes.size) + } + + @Test + fun `an unreachable host during submit is distinguishable from a wrong token`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.Unreachable("IOException") } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertEquals(TokenError.UNREACHABLE, (vm.uiState.value as PairingUiState.TokenRequired).error) + } + + @Test + fun `a prober with no token support reports the capability honestly`() = + runTest(UnconfinedTestDispatcher()) { + // The interface defaults exist so a probe-only seam (a test double) still compiles; they must + // surface as an explicit "cannot submit", never as a silent success. + val probeOnly = object : PairingProber { + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult = + PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) + } + val vm = newVm(probeOnly) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + // Not gated by default → the plain failure card, exactly as before this feature existed. + assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value) + } + + @Test + fun `a blank token is never submitted`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(" ") + + assertTrue(prober.submitted.isEmpty()) + assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value) + } + + @Test + fun `a token submitted outside the prompt is a no-op`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber() + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) // still on the confirm gate — nothing has asked for a token + vm.submitAccessToken(TOKEN) + + assertTrue(prober.submitted.isEmpty()) + assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + } + + // ── The token must not outlive the submit ──────────────────────────────────────────────────────── + + @Test + fun `the token is never held in the UI state or any of its toStrings`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { TokenSubmission.Rejected } + val vm = newVm(prober) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + val promptState = vm.uiState.value + vm.submitAccessToken(TOKEN) + val afterState = vm.uiState.value + + assertFalse(promptState.toString().contains(TOKEN)) + assertFalse(afterState.toString().contains(TOKEN)) + } + + // ── The token path cannot bypass the tunnel cert-gate (RULE 4) ────────────────────────────────── + + @Test + fun `an accepted token still funnels through the device-cert gate`() = + runTest(UnconfinedTestDispatcher()) { + var certInstalled = true + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { prober.gated = false; TokenSubmission.Accepted } + val vm = newVm(prober, hasCert = { certInstalled }) + vm.bind(backgroundScope) + + vm.onManualEntry(TUNNEL) + vm.confirm() + assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value) + + // The cert is removed while the token prompt is open; the post-token probe must re-refuse. + certInstalled = false + vm.submitAccessToken(TOKEN) + + assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value) + assertEquals(1, prober.probes.size, "no network probe without a device cert") + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt index b885e71..59eaaea 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt @@ -10,12 +10,18 @@ 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.assertNotNull import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import wang.yaojia.webterm.api.models.LiveSessionInfo import wang.yaojia.webterm.api.models.SessionPreview +import wang.yaojia.webterm.api.routes.ApiClient +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod import java.util.UUID /** @@ -59,7 +65,7 @@ class ThumbnailPipelineTest { } /** Hands back a fresh mock bitmap per raster (so identity distinguishes renders) + a fixed placeholder. */ - private class FakeRasterizer : ThumbnailRasterizer { + private class FakeRasterizer(private val placeholderFailure: Throwable? = null) : ThumbnailRasterizer { var rasterizeCount = 0 val placeholderBitmap: Bitmap = mockk() @@ -68,7 +74,10 @@ class ThumbnailPipelineTest { return mockk() } - override fun placeholder(): Bitmap = placeholderBitmap + override fun placeholder(): Bitmap { + placeholderFailure?.let { throw it } + return placeholderBitmap + } } /** Map-backed cache seam — keeps LRU KEYING testable without touching the stubbed android.util.LruCache. */ @@ -78,6 +87,10 @@ class ThumbnailPipelineTest { override fun put(key: ThumbnailKey, bitmap: Bitmap) { map[key] = bitmap } + + override fun retainOnly(keys: Set) { + map.keys.retainAll(keys) + } } private fun session(id: UUID, lastOutputAt: Long?): LiveSessionInfo = LiveSessionInfo( @@ -201,6 +214,158 @@ class ThumbnailPipelineTest { assertEquals(1, source.fetchStarts) } + /** + * A rasterizer failure that ALSO breaks the placeholder (a `Bitmap` OOM is the realistic case) must + * surface as `null`, NOT as an exception — the row's `LaunchedEffect` collects this directly, so a + * throw would take down the composition. It must also leave no poisoned in-flight entry: the next + * request retries instead of re-awaiting a permanently-failed `Deferred`. + */ + @Test + fun `an unrenderable thumbnail returns null and is retried rather than poisoning the key`() = runTest { + val source = FakePreviewSource(failure = RuntimeException("boom")) + val raster = FakeRasterizer(placeholderFailure = OutOfMemoryError("bitmap")) + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val s = session(UUID.randomUUID(), lastOutputAt = 9L) + + val first = async { pipeline.thumbnail(s) } + advanceUntilIdle() + assertNull(first.await()) + assertEquals(1, source.fetchStarts) + + // Nothing cacheable ⇒ the key is free again (no stale in-flight Deferred to re-await forever). + val second = async { pipeline.thumbnail(s) } + advanceUntilIdle() + assertNull(second.await()) + assertEquals(2, source.fetchStarts) + } + + /** + * The render lives in the PIPELINE's scope, not the caller's: a card scrolled off-screen mid-render + * (its collector cancelled) must not cancel the shared render another card is awaiting. + */ + @Test + fun `a cancelled caller does not cancel the shared render`() = runTest { + val gate = CompletableDeferred() + val source = FakePreviewSource(block = gate) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val s = session(UUID.randomUUID(), lastOutputAt = 3L) + + val scrolledAway = launch { pipeline.thumbnail(s) } + advanceUntilIdle() + scrolledAway.cancel() + advanceUntilIdle() + + val stillVisible = async { pipeline.thumbnail(s) } + advanceUntilIdle() + gate.complete(Unit) + advanceUntilIdle() + + assertNotNull(stillVisible.await()) + assertEquals(1, source.fetchStarts) // the first render survived the cancelled caller + assertEquals(1, raster.rasterizeCount) + } + + /** + * Bitmaps must not be retained for sessions that are gone (killed/exited) or for superseded + * `lastOutputAt` keys: [ThumbnailPipeline.retainOnly] prunes everything outside the current live list, + * so a still-live session keeps its cache hit while a dead one's bitmap is released. + */ + @Test + fun `retainOnly releases bitmaps for sessions no longer live`() = runTest { + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = FakePreviewSource(), + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val live = session(UUID.randomUUID(), lastOutputAt = 1L) + val dead = session(UUID.randomUUID(), lastOutputAt = 1L) + + val warm = launch { + pipeline.thumbnail(live) + pipeline.thumbnail(dead) + } + advanceUntilIdle() + assertTrue(warm.isCompleted) + assertEquals(2, raster.rasterizeCount) + + pipeline.retainOnly(listOf(live)) + + // The live session is still a cache hit … + val hit = async { pipeline.thumbnail(live) } + advanceUntilIdle() + hit.await() + assertEquals(2, raster.rasterizeCount) + + // … the dead one's bitmap was released, so asking again re-renders (it is no longer retained). + val miss = async { pipeline.thumbnail(dead) } + advanceUntilIdle() + miss.await() + assertEquals(3, raster.rasterizeCount) + } + + @Test + fun `raster budget downscales an oversized terminal raster and leaves small ones alone`() { + // A 161x50 session at 12px monospace rasterises to ~1127x700 = 3.1 MiB ARGB_8888 — three of those + // blow an 8 MiB cache. The budget scales it to the card's resolution, preserving aspect ratio. + val scaled = ThumbnailBudget.scaledSize(width = 1127, height = 700, maxWidth = 480) + assertNotNull(scaled) + assertEquals(480, scaled!!.width) + assertEquals(298, scaled.height) // 700 * 480 / 1127, floored + + // Already inside the budget → no scale step at all (null = draw as rendered). + assertNull(ThumbnailBudget.scaledSize(width = 320, height = 200, maxWidth = 480)) + + // A degenerate ratio never collapses to a zero-height bitmap. + val sliver = ThumbnailBudget.scaledSize(width = 4000, height = 3, maxWidth = 480) + assertNotNull(sliver) + assertEquals(1, sliver!!.height) + } + + /** + * **A thumbnail fetch must NEVER attach.** Looking at the home chooser reads the ring-buffer tail over + * the read-only `GET /live-sessions/:id/preview` route ONLY — it must not open a WS, must not register + * a client, and must not touch the session's idle clock, or merely displaying the grid would keep every + * session alive forever and defeat the idle-reclaim design (plan §5.2 / §6.7). + * + * Asserted at the transport: exactly ONE request, `GET`, on the preview path, carrying **no `Origin` + * header** (which is the marker of a guarded/state-changing route — plan §4.2/§4.3). + */ + @Test + fun `the production preview source issues only the read-only preview GET and never attaches`() = runTest { + val transport = FakeHttpTransport() + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.7:3000")) + val id = UUID.fromString("11111111-2222-4333-8444-555555555555") + val previewUrl = "http://10.0.0.7:3000/live-sessions/$id/preview" + transport.queueSuccess( + method = HttpMethod.GET, + url = previewUrl, + body = """{"id":"$id","cols":80,"rows":24,"data":"hello"}""".toByteArray(Charsets.UTF_8), + ) + + val fetched = ApiPreviewSource { ApiClient(endpoint = endpoint, http = transport) }.fetch(id) + + assertEquals("hello", fetched.data) + assertEquals(1, transport.recordedRequests.size) + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.GET, request.method) + assertEquals(previewUrl, request.url) + assertNull(request.body) + assertNull(request.headers["Origin"], "the RO preview route must not carry Origin: ${request.headers}") + } + @Test fun `preview cap keeps the tail when over 256 KiB and is a no-op under the cap`() { val big = "A".repeat(PreviewCap.MAX_PREVIEW_BYTES + 100) + "TAIL" diff --git a/docs/ANDROID_CLIENT_PLAN.md b/docs/ANDROID_CLIENT_PLAN.md index 5768ecc..d9aaabc 100644 --- a/docs/ANDROID_CLIENT_PLAN.md +++ b/docs/ANDROID_CLIENT_PLAN.md @@ -74,7 +74,7 @@ Reach functional parity with the iOS client's shipped feature set (P0 + P1). Eve - **`DELETE /live-sessions` kill-all / a "manage" page** — **iOS does NOT consume this route**; its swipe-to-kill and pointer-menu kill both use per-session `DELETE /live-sessions/:id`. Out of parity; do not build a kill-all surface iOS lacks. - **On-device WebView/xterm.js fidelity fallback** — only if Termux emulation fails QA (§6.8). Not built by default. - **System-`KeyChain` MDM cert path** — documented fallback only; wrong trust model for a per-device pinned identity. -- **Self-signed-LAN `wss://` custom trust / pinning** — bare LAN uses `ws://` cleartext (allowlisted CIDRs); `wss` is only for the tunnel/Tailscale, which present real CA-signed certs. A `CertificatePinner`/custom `X509TrustManager` path is documented but not built (§6.9, §8). +- **Self-signed-LAN `wss://` custom trust / pinning** — bare LAN uses `ws://` cleartext (permitted in `base-config`; see §6.9 — the platform has no CIDR syntax, so it CANNOT be scoped to RFC1918); `wss` is only for the tunnel/Tailscale, which present real CA-signed certs. A `CertificatePinner`/custom `X509TrustManager` path is documented but not built (§6.9, §8). - **Cross-device palette sync** — matches current design (iOS/web/Android stores are independent). - **Voice commands / editor-open / worktree-create** — server has routes (`/open-in-editor`, `/projects/worktree`) the iOS client does not consume; out of parity scope. @@ -96,7 +96,7 @@ Reach functional parity with the iOS client's shipped feature set (P0 + P1). Eve | Persistence (secret / device cert) | **Google Tink AEAD + AndroidKeystore master key**; **the private key is imported non-exportable into `AndroidKeyStore`** (the runtime home). Tink AEAD encrypts only the **cert chain + metadata** blob at rest. **No `.p12` blob or passphrase is persisted.** | Non-exportable, device-bound = the `…AfterFirstUnlockThisDeviceOnly` analogue. **Not `EncryptedFile`/`security-crypto`** (that Jetpack Security API is deprecated — draft inconsistency resolved). | | Push | **Firebase Cloud Messaging (FCM) v1**, data-only high-priority | Only way to reproduce the lock-screen two-tap Allow/Deny loop (§4.5, §9 R1). | | mTLS | **`AndroidKeyStore`-imported non-exportable key + a custom re-reading `X509KeyManager`** (returns the AndroidKeyStore `PrivateKey`/chain per handshake) → `SSLContext` → OkHttp `sslSocketFactory`. **`KeyStore("PKCS12")` is used transiently only to *parse the import file*, never as the runtime key home.** | App-private identity (NOT system `KeyChain`); re-reading KeyManager + `connectionPool.evictAll()` gives the iOS "no-relaunch cert rotation" behavior. One key home, no contradiction (R4/mustFix). | -| Server-cert trust | **Default system trust** (tunnel `*.terminal.yaojia.wang` + Tailscale MagicDNS present real LE certs). Bare LAN uses **`ws://` cleartext** via a `network_security_config` CIDR allowlist. | Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. | +| Server-cert trust | **Default system trust** (tunnel `*.terminal.yaojia.wang` + Tailscale MagicDNS present real LE certs). Bare LAN uses **`ws://` cleartext**, permitted in `network_security_config`'s `base-config` — the format has NO address-range syntax, so it cannot be narrowed to LAN literals (§6.9). | Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. | | QR scan | **CameraX + ML Kit Barcode** (`com.google.mlkit:barcode-scanning`) | On-device, one validator shared with manual entry. | | Biometric | **`androidx.biometric:BiometricPrompt`** hosted in a `FragmentActivity` (the Allow trampoline) | Gates the Allow action. | | Testing | **JUnit5 + kotlinx-coroutines-test (virtual time) + Turbine + MockK** (unit); **Espresso + Compose UI Test** (instrumented; Robolectric only where it faithfully emulates); **Kover** (coverage) | Mirrors iOS's injected-clock/fake-transport discipline. `AndroidKeyStore`/Tink tests are **instrumented on a real emulator/device** — Robolectric does not provide the AndroidKeyStore provider (platform review). | @@ -429,7 +429,20 @@ Two tiers, chosen by decision gate. **Note the blast radius:** the tier-2 WebVie OkHttp requires an `(SSLSocketFactory, X509TrustManager)` pair. Decision: - **Tunnel (`*.terminal.yaojia.wang`) + Tailscale MagicDNS:** real CA-signed (LE) certs → **default system trust**. No custom trust manager. -- **Bare LAN:** use **`ws://` cleartext**, permitted only for private LAN/Tailscale CIDRs via `network_security_config` (global `usesCleartextTraffic=false`). +- **Bare LAN:** use **`ws://` cleartext**, permitted in `network_security_config`'s `base-config`. + **CORRECTED (was wrong in this plan until the A19 fix):** the original text said cleartext would be + "permitted only for private LAN/Tailscale CIDRs via `network_security_config`". That is not + expressible. The network-security-config format has NO netmask/prefix/CIDR attribute anywhere — a + `` holds one hostname or one IP literal, and `includeSubdomains` walks DNS labels, so it cannot + generalise `192.168.0.0/16`. Enumerating ~18.9M addresses is not an option either. Since a LAN address + is user-typed at runtime and this file is a build-time resource, it also cannot be scoped to "the IP the + user actually entered". So: cleartext is ON in `base-config`, `*.terminal.yaojia.wang` keeps an explicit + `cleartextTrafficPermitted="false"` block (the one hostname known at build time), trust anchors are + pinned to `system` only, and the real guard moves into the app — the §5.4 warning tiers plus + confirm-before-network. The scoping the platform cannot express is additionally enforced at + transport time in `OkHttpClientFactory`, which refuses a plaintext dial to a non-private host. + Honest tradeoff: on bare `ws://` the terminal bytes and the `WEBTERM_TOKEN` cookie are readable and + replayable by anyone on the path. This is a LAN/Tailscale posture, never an internet-exposure one. - **Self-signed LAN `wss`:** DEFERRED — a documented `CertificatePinner`/custom `X509TrustManager` path, not built by default. --- @@ -468,7 +481,7 @@ Gesture/IME/CJK composition, camera-QR, haptics, lock-screen Allow/Deny, FCM end - [ ] **mTLS trust model (one key home)** — private key **imported non-exportable into `AndroidKeyStore`**; a **custom re-reading `X509KeyManager`** returns the AndroidKeyStore `PrivateKey`/chain per handshake; `KeyStore("PKCS12")` is import-parse-only (transient), never the runtime home; **NOT** system `KeyChain`. Client cert presented on both WS and REST via one shared `OkHttpClient`. On rotation, `connectionPool.evictAll()` so pooled/resumed connections drop the old identity. - [ ] **Cert storage** — private key non-exportable/device-bound; only the **cert chain + metadata** blob is stored, encrypted with **Tink AEAD + AndroidKeystore master key**; app-private, uninstall-wiped, never in backups/cloud. **No `.p12` blob or passphrase persisted.** Import validates before persisting so a bad passphrase can't clobber a prior identity. - [ ] **No system-wide credential leakage** — no ephemeral/disk HTTP cache (`OkHttpClient.cache(null)`) — preview/diff bodies can contain terminal secrets. -- [ ] **Cleartext posture** — `usesCleartextTraffic=false` globally; a `network_security_config` allowlist permits `ws://` only for LAN/Tailscale CIDRs; `wss` (tunnel/Tailscale) uses default system trust; §5.4 warning tiers reproduced in-app (public-host blocking needs explicit acknowledge; tunnel host = cert-gated, warning softened, TLS failure re-mapped to "client cert invalid/revoked"). +- [x] **Cleartext posture** — cleartext permitted in `base-config` (NO CIDR syntax exists — see §6.9), `*.terminal.yaojia.wang` explicitly denied, trust anchors `system`-only, no `debug-overrides`, and a transport-time private-host check in `OkHttpClientFactory`; locked by `NetworkSecurityConfigTest`; `wss` (tunnel/Tailscale) uses default system trust; §5.4 warning tiers reproduced in-app (public-host blocking needs explicit acknowledge; tunnel host = cert-gated, warning softened, TLS failure re-mapped to "client cert invalid/revoked"). - [ ] **Tunnel-host cert gate** — `runProbe` refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed; both confirm and retry funnel through the one choke point (retry can't bypass). - [ ] **Push token discipline** — single-use `/hook/decision` capability token validated as v4 UUID, passed only to the POST, **never persisted, never logged, never in fallback copy**; payload minimized (`sessionId`/`cls`/`token` only). - [ ] **Notification-action trust split** — **Deny → `BroadcastReceiver`** (`goAsync()`, no UI, expedited POST). **Allow → a translucent, `excludeFromRecents` trampoline `Activity`** that hosts `BiometricPrompt` then POSTs — a `BroadcastReceiver`/`Service` cannot present `BiometricPrompt` and `goAsync()`'s ~10s budget would blow. (Corrects the draft's "no app open" claim for Allow.) All `PendingIntent`s `FLAG_IMMUTABLE` (API 31+). Deny is auth-free (fail-safe). The decision POST is **expedited** (not deferrable `WorkManager`); the single-use token makes a retried-after-success POST return 403 → idempotent retry-safety. diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 1f036d1..9eb9779 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,32 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### 🤖 [~] Android 客户端"能装但不能用"修复 — blocker 已清,设备验证未做(2026-07-30,worktree `android-blocker-fixes`) + +- **起因**: 例行问"android 客户端完成状况如何"。`android/PROGRESS_ANDROID.md` 写着 **"✅ ANDROID CLIENT COMPLETE — all 36 plan tasks landed"**,617 个 JVM 测试全绿,APK 也出得来。**这个"完成"只在编译层面成立** —— 从来没在任何真机或模拟器上跑过,而一旦跑,第一个动作就崩。审计(45 agent,含逐条对抗验证)+ 修复(多波 agent)已把 blocker 清掉。 +- **为什么 JVM 测试全绿也漏掉了**: 一个结构性原因 —— **没有任何 JVM 测试会实例化 Android `View`**。`:terminal-view` 那 17 个测试测的是纯逻辑,渲染/输入/触摸整条路径无人触碰。 +- **三个让 App 在真机上不可用的缺陷**(每一条都反编译 `terminal-view-v0.118.0.aar` 逐偏移确认): + 1. **按任何键必崩**。`RemoteTerminalView` 只绑了 emulator,从没调用 `setTerminalViewClient` —— KDoc 写着"A17 会装",A17 实际只交付了 `HardwareKeyRouter` 和 KeyBar(而 KeyBar 之所以能用,恰恰因为它绕过了 view)。stock `TerminalView` 在 `onKeyDown` offset 82、`onCreateInputConnection` offset 0、`onKeyUp`、`onKeyPreIme` 无判空解引用 `mClient`。**装个 client 还不够**:返回 false 会继续走到 offset 150 的 `mTermSession.write()`,而 `mTermSession` 永远是 null(`TerminalSession` 是 final,而 fork 进程正是本 App 绝不能做的事,§6.1)。修法:client 自己消费按键,且 `KeyRouting` **刻意没有"交还 stock"这个分支** —— 崩溃变成不可表达,而不只是被绕开。键表仍由 Termux `KeyHandler` 决定,DECCKM 照旧发 `ESC O A`。 + 2. **resize 从来不发**。`RemoteTerminalSession.updateSize` 零调用点,stock 兜底也是死的(`TerminalView.updateSize` 无 session 时 offset 18-25 直接 return),PTY 一直停在服务端的 80×24,所有全屏 TUI 都排版进错误的框。`TerminalResizeDriver` 现在从真实字距驱动 —— 字距走**公开** accessor `TerminalRenderer.getFontWidth()`/`getFontLineSpacing()`(agent 自己 `javap` 发现它们是 public),**不用反射**(R8 下会静默失效)也**不重测 Paint**(会与渲染器实际所用漂移,正是 §R5 风险)。 + 3. **局域网连不上**。`network_security_config.xml` 是自己标着 STUB 的全局禁明文,而 `HostEndpoint` 接受 `http` 并派生 `ws://`。它注释里承诺的 CIDR 白名单**平台根本不支持**(NSC 格式没有任何 netmask/prefix 属性,`` 只装一个主机名或一个 IP 字面量)。改为 base-config 放开 + `*.terminal.yaojia.wang` 显式禁明文 + 信任锚钉死 system + `OkHttpClientFactory` 在传输层拒绝向非私有地址发起明文 —— 平台表达不了的收窄,放到 HTTP 客户端里做。plan §6.9/§8 的错误描述已一并更正。 +- **对抗评审又挖出三个,其中一条评审自己判错了**: + - **在 alternate-screen TUI 里滑动滚屏仍会崩**。触摸路径完全绕过 `TerminalViewClient`:`doScroll` 在 alternate buffer 激活时把滚动转成 `handleKeyCode`,而那里解引用 `mTermSession`。**Claude Code 本身就是 alternate-screen TUI**,所以这是主界面正常使用中的崩溃。`TerminalScrollGesture` 抢占纵向拖动并复现 `doScroll` 的三个分支,连 fling runnable 和 `onGenericMotionEvent` 一并封死。 + - **`autofill()` 无判空解引用 `mTermSession`**,而该 view 自称可自动填充 → 任何密码管理器一填就崩。整个子树已排除出 autofill 结构。 + - 评审要求**恢复** stock 文字选择(此前被 focus 改造弄成不可达)。**这条它错了**:ActionMode 自己的 Copy/Paste 处理器也解引用空 session(`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136),恢复可达性只会把崩溃搬到用户点 Copy 那一刻 —— 比没有选择功能更糟。**长按保持被消费,复制功能如实记为功能缺口**(真正的修法是基于 `TerminalBuffer` + `ClipboardManager` 自建选择层)。 +- **被静默丢弃的服务端数据,现已解码**: W2 `queue` 帧在 `ServerMessageType` 里没有成员,`decodeServer` 返回 null,`SessionEngine` 当"无法解码"丢掉 —— "N queued"徽标在 Android 上**从来不可能出现**;W1 `status.preview` 完全没建模,导致远程审批是**盲批**(只看到工具名,看不到要执行的命令/diff)。两者均已解码。一条规矩用测试钉住:**preview 坏掉绝不能连帧一起丢** —— `pending` 才是让 gate 出现的信号。 +- **当作功能发布的死代码**: `ThumbnailPipeline`(会话列表预览)、`QuickReply`(芯片+调色板)、`PointerContextMenu` 三者实现完整、单测齐全、**生产代码零调用点**。均已接线。`ThumbnailPipeline` 当年被**明确跳过**的交叉评审(AW3 条目自己承认)这次补做了。 +- **凭据处理**: `WEBTERM_TOKEN` cookie 现在在 REST 和 WS upgrade 上都携带,用 Tink AEAD + AndroidKeyStore 封装,**fail-closed**(封装失败就不持久化,绝不退回明文);`allowBackup=false` 阻止这个 30 天 shell 凭据经 Google Drive / D2D 外泄。 +- **v0.6 git 面板数据层**: 服务端 8fe1f52 之后的字段 Android 全没解码。已补 `SyncState`/`dirtyCount`/`cwd`/`unpushed`/`upstream` + `GET /projects/worktree/state` + `POST /projects/git/fetch`。两条易错语义**编码成行为**而非靠调用点自觉:`SyncState.isFullySynced(now)` 是唯一被认可的"能否显示绿色"判断(任何未知即 false,**无 upstream ≠ 0**);`GitLogResult.upstreamBoundaryIndex` 找**最后一个**未推送提交(取前 N 行会朝危险方向出错)。 +- **验证**: `./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify` —— blocker 修复提交时 **757 个 JVM 测试全绿**(基线 617);`:wire-protocol`/`:session-core`/`:api-client` 三处 koverVerify 均过。commit `3e5cfdc`、`3fe719f`、`35d32f4`。 +- **方法与其代价(如实记录)**: 分波多 agent(TDD builder → 对抗评审 → 修复)。**API 端持续 529 过载,13 个 agent 里 8 个、11 个里 9 个被打掉**;协议层和 REST 层两轮全灭后由 orchestrator 亲自补写。这也是为什么会话成本冲到 ~$360。对抗评审确实值 —— 滑动滚屏那个 CRITICAL 崩溃是 builder 漏掉、reviewer 抓到的。 +- **仍未完成(不要把上面读成已完成)**: + - **A34 / A35 完全没有代码** —— `:app` 连 androidTest 源集都没有,也没有 benchmark 模块。AW6 条目把它们标成 `[x]` 是因为它们被转成了清单条目,不是实现。 + - **设备 QA 仍约 0%** —— 仓库里唯一的真机证据是一张配对页的模拟器截图。上面每一条修复都只有 JVM 级验证。 + - release 签名/版本号/minify;S2 FCM 真机投递;复制功能。 + - `android/PROGRESS_ANDROID.md` 顶部已加"CORRECTION"段落说明上述全部。 +- **下一步**: 模拟器(AVD `webterm`,已启动)上跑 instrumented 回归 —— 尤其是这次修掉的三个崩溃,那正是 JVM 测试抓不到、也正是本次修复存在的理由。 + + ### 🌿 [x] w6 项目详情 Git 面板 — G1–G7 全部完成(2026-07-29,worktree `project-detail-git-mockup`) - **动机**: 详情页只有一个光秃秃的 `●`,回答不了"有没有 commit 没 push / 现在在哪个 worktree"。设计稿 `docs/mockups/project-detail-git.html`,计划 `docs/plans/w6-project-git-panel.md`。 From b09b90e5bb3d83da1b5248a3c0b327dcbb8437d6 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 12:09:25 +0200 Subject: [PATCH 05/10] fix(android): the ThumbnailPipeline bugs its skipped review would have caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This file's cross-review was explicitly skipped when it was written — PROGRESS_ANDROID.md says so — and it had no production call sites until this session, so nothing had ever reviewed OR executed it. Wiring it to the home screen finally made an adversarial concurrency review worthwhile. It confirmed the hard parts are right (the check-then-insert dedup critical section, withPermit's exactly-once release with the fallback outside it, the NonCancellable finally, and rethrowing CancellationException) and then found five real defects. Every premise was re-verified from the termux v0.118.0 bytecode before fixing. 1. Thumbnails drew DUPLICATED ROWS. TerminalBuffer.externalToInternalRow is `(mScreenFirstRow + row) % mTotalRows` and mTotalRows is the constructor's transcriptRows, so passing TERMINAL_TRANSCRIPT_ROWS_MIN (100) while rows went up to 200 aliased external rows 100..199 onto 0..99 — and left the emulator's scroll state invalid during append(). A pure ThumbnailGrid now derives transcriptRows = max(rows, MIN). 2. A legitimately-sized session was LMK bait. cols/rows are server-supplied and the protocol validates resize only to [1,1000], so a 1000x1000 session forced 2800x2800 ARGB_8888 = 31.4 MB, twice over for two permits, every poll tick. Rather than clamp the grid — which would CROP a real screen — the cell size is now derived from the target, shrinking the font's own cell by an exact integer fraction with the aspect preserved. Any geometry in 1..1000 now yields at most 480x480 = 900 KiB; a real 161x50 goes from 3.1 MB to 322 KiB. 3. Two stalled fetches wedged the feature PERMANENTLY. There is no OkHttp callTimeout (the per-socket read timeout is reset by every trickled byte) and the render is deliberately uncancellable by any caller, so two slow-trickle previews held both permits until close() and no thumbnail rendered again. A withTimeout now sits inside withPermit, budgeting the work rather than the permit wait. The subtlety that makes this correct: TimeoutCancellationException IS a CancellationException, so it is caught BEFORE that branch and routed to the placeholder — otherwise a slow host would still kill the pipeline instead of degrading. 4. The in-flight entry was leakable, contradicting a comment that claimed it never was. `scope.async` on an already-cancelled scope never runs its body, so the finally never ran: a post-close() request left a dead Deferred that every later request awaited forever, and inFlight grew unbounded. Now guarded inside the mutex, and the comment names the one path that genuinely cannot release (where the map dies with the pipeline anyway). 5. Obsolete renders did guaranteed wasted work. lastOutputAt advances on every PTY byte and the list polls every 5s, so an active session mints a new key each tick; the row cancelled the old caller but the old render ran a full GET plus a full raster whose bitmap was cached under a key nothing would request again — occupying both permits ahead of the key actually on screen, and growing without bound once render latency exceeded arrival rate. A newer key now cancels the older in-flight render for that session. Also made two false statements true rather than leaving them: retainOnly now prunes under the same monitor publish() takes, so a render landing after the prune cannot resurrect a killed session's bitmap; and PreviewCap's KDoc no longer claims to prevent an oversized-body OOM (the body is already buffered and parsed by then) nor to "mirror the server's cap" (the server defaults to 24 KiB, not 256 KiB, and an operator can raise it unbounded). RED was observed for all four behavioural fixes before implementing. Verified: ./gradlew test :app:testDebugUnitTest :app:assembleDebug koverVerify -> BUILD SUCCESSFUL, 901 JVM tests, 0 failures (893 -> 901). The agent distrusted a FROM-CACHE green, forced a real execution, and confirmed the code reached the packaged APK by scanning its dex. --- android/.gitignore | 3 + .../wang/yaojia/webterm/nav/SessionsHome.kt | 6 +- .../wiring/TerminalThumbnailRasterizer.kt | 239 +++++++++++--- .../webterm/wiring/ThumbnailPipeline.kt | 168 ++++++++-- .../webterm/wiring/ThumbnailPipelineTest.kt | 301 +++++++++++++++++- 5 files changed, 640 insertions(+), 77 deletions(-) diff --git a/android/.gitignore b/android/.gitignore index 2c52a7b..96325e0 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -40,3 +40,6 @@ service-account*.json # Kover / test reports **/kover/ + +# Kotlin compiler session/error scratch (KGP 2.x writes this at the project root) +.kotlin/ diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt index ba4b69e..5f8a290 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt @@ -118,8 +118,10 @@ public fun SessionsHome( val thumbnails: SessionThumbnails? = remember(pipeline) { pipeline?.let { live -> SessionThumbnails { session -> live.thumbnail(session) } } } - // Release bitmaps for sessions that are no longer live (killed/exited) and superseded lastOutputAt - // keys — a thumbnail must never outlive the session it depicts. + // Release bitmaps for sessions that are no longer live (killed/exited) and superseded lastOutputAt keys, + // so the cache only ever holds screens this list can still show. A render already in flight for a + // just-killed session cannot re-insert its bitmap behind this prune (ThumbnailPipeline.retainOnly closes + // its publish gate); a bitmap outlives its session only for as long as a row keeps asking for it. LaunchedEffect(pipeline, state.rows) { pipeline?.retainOnly(state.rows.map { it.info }) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt index c8f5bfd..9b874f0 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt @@ -24,16 +24,30 @@ import kotlin.math.ceil * * `TerminalRenderer`'s own `mFontWidth`/`mFontLineSpacing` are package-private, so this computes its own * cell metrics from the [Paint] (`ceil(measureText("X"))` and the ascent-to-descent span), exactly as - * `TerminalRenderer` derives them. + * `TerminalRenderer` derives them — as a TEMPLATE that [ThumbnailBudget.cellMetrics] then shrinks to the + * thumbnail budget, so the raster is born at roughly the size the card displays. + * + * ### Two bounds, both server-input driven (both are pure and JVM-tested) + * - [ThumbnailGrid] clamps `cols`/`rows` (SERVER-supplied; `src/protocol.ts` validates resize only to + * 1..1000) and — load-bearing — derives the emulator's `transcriptRows` so it is never below the screen + * height. Termux's `TerminalBuffer.externalToInternalRow` is `(mScreenFirstRow + row) % mTotalRows`, so + * a `transcriptRows < rows` buffer ALIASES external rows onto each other and the thumbnail draws + * duplicated rows (this was the bug: a fixed `TERMINAL_TRANSCRIPT_ROWS_MIN` = 100 against `rows` ≤ 200). + * - [ThumbnailBudget.cellMetrics] shrinks the CELL so the full-resolution intermediate can never exceed + * [ThumbnailBudget.MAX_WIDTH_PX] × [ThumbnailBudget.MAX_HEIGHT_PX] (≤ 900 KiB ARGB_8888) — previously a + * legitimately-sized 1000×1000 session forced a 31 MB allocation per render, ×2 concurrent permits, on + * every 5 s poll tick, which degrades safely (OOM is caught) but invites an LMK kill of the whole app. * * android.graphics is stubbed in JVM unit tests, so this class is exercised by device QA (plan §7, * `:terminal-view` instrumented "thumbnail rasterisation non-null"); the pipeline policy around it is - * JVM-tested via the [ThumbnailRasterizer] seam. + * JVM-tested via the [ThumbnailRasterizer] seam, and its two size bounds via [ThumbnailGrid] / + * [ThumbnailBudget] (pure) plus a real off-screen [TerminalEmulator] (pure Java — no android.graphics). */ public class CanvasThumbnailRasterizer( - fontSizePx: Float = DEFAULT_FONT_SIZE_PX, + private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX, typeface: Typeface = Typeface.MONOSPACE, private val maxWidthPx: Int = ThumbnailBudget.MAX_WIDTH_PX, + private val maxHeightPx: Int = ThumbnailBudget.MAX_HEIGHT_PX, ) : ThumbnailRasterizer { /** @@ -46,65 +60,93 @@ public class CanvasThumbnailRasterizer( this.typeface = typeface textSize = fontSizePx } - private val cellWidth: Int = ceil(metricsPaint.measureText("X").toDouble()).toInt().coerceAtLeast(1) - private val cellHeight: Int = + + /** The UNSCALED cell size at [fontSizePx]; [ThumbnailBudget.cellMetrics] shrinks it per render. */ + private val templateCellWidth: Int = ceil(metricsPaint.measureText("X").toDouble()).toInt().coerceAtLeast(1) + private val templateCellHeight: Int = (metricsPaint.fontMetricsInt.descent - metricsPaint.fontMetricsInt.ascent).coerceAtLeast(1) - private val baselineOffset: Int = -metricsPaint.fontMetricsInt.ascent + private val templateBaseline: Int = -metricsPaint.fontMetricsInt.ascent override fun rasterize(preview: SessionPreview): Bitmap { - val cols = preview.cols.coerceIn(1, MAX_COLS) - val rows = preview.rows.coerceIn(1, MAX_ROWS) - val emulator = TerminalEmulator(NoOpOutput, cols, rows, TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN, NoOpClient) + val grid = ThumbnailGrid.of(preview.cols, preview.rows) + val cell = ThumbnailBudget.cellMetrics( + cols = grid.cols, + rows = grid.rows, + templateWidth = templateCellWidth, + templateHeight = templateCellHeight, + maxWidth = maxWidthPx, + maxHeight = maxHeightPx, + ) + val emulator = TerminalEmulator(NoOpOutput, grid.cols, grid.rows, grid.transcriptRows, NoOpClient) val bytes = preview.data.toByteArray(Charsets.UTF_8) emulator.append(bytes, bytes.size) - val paint = Paint(metricsPaint) // per-render Paint — see [metricsPaint] - val full = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, Bitmap.Config.ARGB_8888) + // Per-render Paint (see [metricsPaint]); its text size follows the shrunken cell, so a glyph never + // smears across the neighbouring cells of a downsized grid. + val paint = Paint(metricsPaint).apply { textSize = fontSizePx * cell.textScale } + val geometry = RenderGeometry( + cols = grid.cols, + cellWidth = cell.width, + cellHeight = cell.height, + baseline = (templateBaseline * cell.textScale).toInt().coerceIn(1, cell.height), + ) + val full = Bitmap.createBitmap( + grid.cols * cell.width, + grid.rows * cell.height, + Bitmap.Config.ARGB_8888, + ) val canvas = Canvas(full) val palette = emulator.mColors.mCurrentColors val defaultBg = palette[TextStyle.COLOR_INDEX_BACKGROUND] canvas.drawColor(defaultBg) - for (row in 0 until rows) { - drawRow(canvas, paint, emulator, row, cols, palette, defaultBg) + for (row in 0 until grid.rows) { + drawRow(canvas, paint, emulator, row, geometry, palette, defaultBg) } return downscaleToBudget(full) } override fun placeholder(): Bitmap { - val bitmap = Bitmap.createBitmap(cellWidth, cellHeight, Bitmap.Config.ARGB_8888) + val bitmap = Bitmap.createBitmap(templateCellWidth, templateCellHeight, Bitmap.Config.ARGB_8888) bitmap.eraseColor(PLACEHOLDER_COLOR) return bitmap } /** - * Shrink a full-resolution raster to the thumbnail budget. A 161×50 session rasterises to ~1127×700 = - * **3.1 MiB** ARGB_8888 — three of those alone exceed the pipeline's cache budget and would thrash the - * LRU on a list of sessions, re-rendering on every scroll. The card is ~96dp wide, so the full raster - * is downscaled once, here, and only the small bitmap is ever cached; the oversized source is recycled - * immediately. + * Safety net over [ThumbnailBudget.cellMetrics]: the cell shrink already keeps the raster inside the + * budget, so this is normally a no-op (`scaledSize` returns `null` ⇒ no second allocation). It stays + * because it is the ONE place that bounds a bitmap that arrived larger than the budget by any route, + * and it recycles the oversized source immediately rather than caching multiple MiB per session. */ private fun downscaleToBudget(full: Bitmap): Bitmap { - val target = ThumbnailBudget.scaledSize(full.width, full.height, maxWidthPx) ?: return full + val target = ThumbnailBudget.scaledSize(full.width, full.height, maxWidthPx, maxHeightPx) ?: return full val scaled = Bitmap.createScaledBitmap(full, target.width, target.height, true) if (scaled !== full) full.recycle() return scaled } + /** Immutable per-render draw geometry (the grid width plus the px size of one cell). */ + private data class RenderGeometry( + val cols: Int, + val cellWidth: Int, + val cellHeight: Int, + val baseline: Int, + ) + private fun drawRow( canvas: Canvas, paint: Paint, emulator: TerminalEmulator, row: Int, - cols: Int, + geometry: RenderGeometry, palette: IntArray, defaultBg: Int, ) { val screen = emulator.screen val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)) val text = line.mText - val top = (row * cellHeight).toFloat() - val baseline = (row * cellHeight + baselineOffset).toFloat() - for (col in 0 until cols) { + val top = (row * geometry.cellHeight).toFloat() + val baseline = (row * geometry.cellHeight + geometry.baseline).toFloat() + for (col in 0 until geometry.cols) { val style = line.getStyle(col) val effect = TextStyle.decodeEffect(style) val bold = (effect and (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0 @@ -114,11 +156,11 @@ public class CanvasThumbnailRasterizer( var bg = resolveColor(TextStyle.decodeBackColor(style), palette, boldBright = false) if (inverse) { val swap = fg; fg = bg; bg = swap } - val left = (col * cellWidth).toFloat() + val left = (col * geometry.cellWidth).toFloat() if (bg != defaultBg) { paint.color = bg paint.style = Paint.Style.FILL - canvas.drawRect(left, top, left + cellWidth, top + cellHeight, paint) + canvas.drawRect(left, top, left + geometry.cellWidth, top + geometry.cellHeight, paint) } val start = line.findStartOfColumn(col) val end = line.findStartOfColumn(col + 1) @@ -142,13 +184,9 @@ public class CanvasThumbnailRasterizer( } public companion object { - /** Default glyph size for a thumbnail (px). Small — the card downscales anyway. */ + /** Default glyph size for a thumbnail (px). Small — the cell shrink and the card scale it anyway. */ public const val DEFAULT_FONT_SIZE_PX: Float = 12f - /** Clamp the off-screen grid so a rogue preview geometry can't allocate an enormous bitmap. */ - public const val MAX_COLS: Int = 400 - public const val MAX_ROWS: Int = 200 - /** Opaque dark-grey placeholder for the fetch/render-failure fallback. */ public const val PLACEHOLDER_COLOR: Int = -0xdfdfe0 // 0xFF202020 @@ -158,29 +196,144 @@ public class CanvasThumbnailRasterizer( } /** - * The pure raster-size budget for a preview thumbnail: how large a rasterised terminal screen may be kept. - * Split out of [CanvasThumbnailRasterizer] so the arithmetic is JVM-unit-testable (android.graphics is - * stubbed off-device), while the `Bitmap`/`Canvas` work stays device-QA. + * The off-screen emulator's GRID, derived from a preview's SERVER-supplied geometry. Pure, so the two + * things that are easy to get catastrophically wrong are JVM-testable (android.graphics is stubbed + * off-device). + * + * `cols`/`rows` come off the wire: `src/protocol.ts` validates a resize only to 1..1000, so a preview may + * legitimately claim 1000×1000 and the grid must be clamped before anything is allocated from it. + */ +public object ThumbnailGrid { + /** + * Grid clamp, tied to the raster budget rather than picked by feel: one cell can never be narrower or + * shorter than 1 px, so a grid wider than [ThumbnailBudget.MAX_WIDTH_PX] (or taller than + * [ThumbnailBudget.MAX_HEIGHT_PX]) could not fit the budget at ANY cell size. Everything a real + * terminal reports — 80×24 up to a 4K-monitor 400×120 — is far inside this, so the clamp only ever + * crops geometry no terminal actually has. + */ + public const val MAX_COLS: Int = ThumbnailBudget.MAX_WIDTH_PX + public const val MAX_ROWS: Int = ThumbnailBudget.MAX_HEIGHT_PX + + /** The clamped screen grid plus the emulator's scrollback depth. [transcriptRows] is always ≥ [rows]. */ + public data class Grid(val cols: Int, val rows: Int, val transcriptRows: Int) + + /** + * Clamp [cols]/[rows] into the budget and pick the emulator's `transcriptRows`. + * + * **`transcriptRows` MUST be ≥ `rows`.** Termux's `TerminalBuffer` keeps `mTotalRows = transcriptRows` + * and maps `externalToInternalRow(row) = (mScreenFirstRow + row) % mTotalRows`, so a buffer with fewer + * total rows than screen rows silently aliases external row *n* onto *n − mTotalRows* — the thumbnail + * would draw duplicated rows and the emulator would scroll against invalid state during `append`. + * The floor is `TERMINAL_TRANSCRIPT_ROWS_MIN` because `TerminalEmulator` itself rejects anything below + * it (falling back to its 2000-row default), which is more scrollback than a preview ever needs. + */ + public fun of(cols: Int, rows: Int): Grid { + val clampedRows = rows.coerceIn(1, MAX_ROWS) + return Grid( + cols = cols.coerceIn(1, MAX_COLS), + rows = clampedRows, + transcriptRows = clampedRows.coerceAtLeast(TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN), + ) + } +} + +/** + * The pure raster-size budget for a preview thumbnail: how large a rasterised terminal screen may be, both + * as drawn ([cellMetrics]) and as kept ([scaledSize]). Split out of [CanvasThumbnailRasterizer] so the + * arithmetic is JVM-unit-testable (android.graphics is stubbed off-device), while the `Bitmap`/`Canvas` + * work stays device-QA. + * + * All of it is integer arithmetic on purpose: a float `floor` could round a cell size *up* past the bound + * it is supposed to enforce, and the bound is the thing standing between a rogue geometry and an LMK kill. */ public object ThumbnailBudget { /** - * Max cached thumbnail width (px). The row tile is ~96dp (≈288px at 3× density) and drawn with - * `ContentScale.Crop`, so 480px keeps it crisp on any density while capping one cached bitmap at a few - * hundred KiB instead of the multi-MiB full-resolution raster. + * Max thumbnail width (px). The row tile is ~96dp (≈288px at 3× density) and drawn with + * `ContentScale.Crop`, so 480px keeps it crisp on any density. */ public const val MAX_WIDTH_PX: Int = 480 + /** + * Max thumbnail height (px). A terminal screen is wider than tall (80×24 lands near 480×288), so this + * only binds a tall/narrow session — but it MUST exist: with a width-only bound a 20×200 session was + * unbounded in height (it rasterised to 140×2800 = 1.5 MiB and was cached at full size, because its + * width was already inside the budget). + */ + public const val MAX_HEIGHT_PX: Int = 480 + /** A raster target size in px. */ public data class RasterSize(val width: Int, val height: Int) + /** The px size of ONE cell, plus the text-size factor that keeps glyphs inside it. */ + public data class CellMetrics(val width: Int, val height: Int, val textScale: Float) + /** - * The size [width]×[height] should be scaled to, or `null` when it already fits [maxWidth] (draw as - * rendered — no extra allocation). Aspect ratio is preserved and the height never collapses to 0. + * The cell size to draw [cols]×[rows] at, shrunk from the font's own [templateWidth]×[templateHeight] + * so the whole raster fits [maxWidth]×[maxHeight] — the screen is never cropped to fit, the cells are. + * + * Guarantees, for `cols ≤ maxWidth` and `rows ≤ maxHeight` (which [ThumbnailGrid] enforces): + * - `cols * width ≤ maxWidth` and `rows * height ≤ maxHeight` — so the ARGB_8888 intermediate is at + * most `maxWidth * maxHeight * 4` bytes (900 KiB at the defaults) whatever the server reports; + * - the cell aspect ratio is preserved (both axes take the SAME scale factor), so text keeps its + * shape instead of being squeezed on one axis; + * - never upscales (the factor is capped at 1) and never returns a 0-px cell. */ - public fun scaledSize(width: Int, height: Int, maxWidth: Int = MAX_WIDTH_PX): RasterSize? { - if (width <= 0 || height <= 0 || width <= maxWidth) return null - val scaledHeight = (height.toLong() * maxWidth / width).toInt().coerceAtLeast(1) - return RasterSize(width = maxWidth, height = scaledHeight) + public fun cellMetrics( + cols: Int, + rows: Int, + templateWidth: Int, + templateHeight: Int, + maxWidth: Int = MAX_WIDTH_PX, + maxHeight: Int = MAX_HEIGHT_PX, + ): CellMetrics { + val cellWidth = templateWidth.coerceAtLeast(1) + val cellHeight = templateHeight.coerceAtLeast(1) + val scale = fitScale( + width = cols.coerceAtLeast(1).toLong() * cellWidth, + height = rows.coerceAtLeast(1).toLong() * cellHeight, + maxWidth = maxWidth, + maxHeight = maxHeight, + ) + return CellMetrics( + width = scale.scaled(cellWidth), + height = scale.scaled(cellHeight), + textScale = scale.factor, + ) + } + + /** + * The size [width]×[height] should be scaled to, or `null` when it already fits [maxWidth]×[maxHeight] + * (draw as rendered — no extra allocation). Aspect ratio is preserved (the tighter axis wins) and + * neither side ever collapses to 0. + */ + public fun scaledSize( + width: Int, + height: Int, + maxWidth: Int = MAX_WIDTH_PX, + maxHeight: Int = MAX_HEIGHT_PX, + ): RasterSize? { + if (width <= 0 || height <= 0) return null + if (width <= maxWidth && height <= maxHeight) return null + val scale = fitScale(width.toLong(), height.toLong(), maxWidth, maxHeight) + return RasterSize(width = scale.scaled(width), height = scale.scaled(height)) + } + + /** + * The largest factor ≤ 1 that fits [width]×[height] inside [maxWidth]×[maxHeight], as an EXACT fraction + * (see the class note on integer arithmetic — a float would round a size up past the bound it enforces). + */ + private fun fitScale(width: Long, height: Long, maxWidth: Int, maxHeight: Int): Scale = when { + width <= maxWidth && height <= maxHeight -> Scale(1L, 1L) + maxWidth.toLong() * height <= maxHeight.toLong() * width -> Scale(maxWidth.toLong(), width) + else -> Scale(maxHeight.toLong(), height) + } + + /** A shrink factor `num/den` (≤ 1), applied to px sizes by exact integer division. */ + private data class Scale(private val num: Long, private val den: Long) { + /** [px] shrunk by this factor, floored, never below 1 px. */ + fun scaled(px: Int): Int = (px * num / den).toInt().coerceAtLeast(1) + + val factor: Float get() = num.toFloat() / den.toFloat() } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt index c6ed37f..363bb3f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.currentCoroutineContext @@ -17,10 +18,12 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import wang.yaojia.webterm.api.models.LiveSessionInfo import wang.yaojia.webterm.api.models.SessionPreview import wang.yaojia.webterm.api.routes.ApiClient import java.util.UUID +import java.util.concurrent.ConcurrentHashMap /** * Off-screen terminal-preview rasterisation for the session-list / manage-page thumbnails (plan §6.7). @@ -32,7 +35,7 @@ import java.util.UUID * [ThumbnailRasterizer] seam so this policy (cache, concurrency, dedup, failure fallback) stays * JVM-unit-testable while the pixel raster is verified on-device (android.graphics is stubbed off-device). * - * ### The four policies (plan §6.7) + * ### The policies (plan §6.7) * - **Cache** — an [LruCache] keyed on `(sessionId, lastOutputAt)`. `lastOutputAt` is the server's * last-PTY-output timestamp, so an UNCHANGED `lastOutputAt` means the screen is byte-identical and we * return the cached bitmap WITHOUT re-fetching or re-rendering. Keying goes through the [BitmapCache] @@ -41,8 +44,16 @@ import java.util.UUID * grid of many cards never spawns dozens of concurrent emulators / large-bitmap allocations. * - **In-flight dedup** — a `Map>` under a [Mutex]: a second request for a key * already rendering AWAITS the first's [Deferred] instead of starting a duplicate fetch/render. - * - **Failure fallback** — any fetch/render failure caches the rasterizer's PLACEHOLDER bitmap under the - * same key, so a broken/oversized session is not retried in a hot scroll loop. + * - **Supersede (coalesce per session)** — `lastOutputAt` advances on every PTY output and the list polls + * every 5 s, so an ACTIVE session mints a new key on every tick. Only the newest key per session id may + * be in flight: a newer key CANCELS the older render, which both frees its permit for the key the row is + * actually showing (otherwise obsolete work queues ahead of it and the tile visibly lags) and stops the + * unbounded growth of `inFlight` once per-render latency exceeds the 5 s arrival rate on a slow link. + * - **Timeout** — the fetch+render of one thumbnail is bounded (see [DEFAULT_RENDER_TIMEOUT_MS]). Without + * it two slow-trickle previews hold both permits forever (OkHttp sets no `callTimeout`, and its + * per-socket read timeout is reset by every trickled byte) and NO thumbnail renders again until [close]. + * - **Failure fallback** — any fetch/render failure, timeout included, caches the rasterizer's PLACEHOLDER + * bitmap under the same key, so a broken/oversized session is not retried in a hot scroll loop. * * The render coroutine runs in this pipeline's own [scope] (not the caller's), so a caller that scrolls * a card off-screen and cancels its collect never cancels a shared render other cards still await. @@ -53,6 +64,7 @@ public class ThumbnailPipeline( private val cache: BitmapCache = LruBitmapCache(DEFAULT_CACHE_BYTES), dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Default, maxConcurrent: Int = MAX_CONCURRENT_RENDERS, + private val renderTimeoutMs: Long = DEFAULT_RENDER_TIMEOUT_MS, ) { private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) @@ -63,16 +75,31 @@ public class ThumbnailPipeline( private val inFlight = HashMap>() private val inFlightMutex = Mutex() + /** + * The newest key requested per session id — the supersede rule's state, and the cache-publish gate. + * Concurrent (not [inFlightMutex]-guarded) because [retainOnly] is a plain, non-suspending call from a + * `LaunchedEffect` and must be able to drop a killed session's entry without a coroutine. + */ + private val latestKeyBySession = ConcurrentHashMap() + + /** + * Orders a cache PUBLISH against a concurrent [retainOnly] prune, so a render that finishes after its + * session was pruned cannot re-insert a bitmap the prune just dropped. Always the INNERMOST lock (never + * held across a suspension), so it cannot participate in a cycle with [inFlightMutex]. + */ + private val publishLock = Any() + /** * The cached (or freshly rendered) thumbnail for [session], or `null` when no bitmap at all could be * produced. Returns the cached bitmap immediately when `(id, lastOutputAt)` is unchanged; otherwise * fetches the preview over mTLS, rasterises off-screen, caches, and returns it. * - * **Never throws for a fetch/render failure** — a fetch/raster failure caches the rasterizer's - * placeholder (no retry storm), and the residual case where even the placeholder cannot be allocated - * (a `Bitmap` OOM) degrades to `null` rather than an exception: the caller is a row's `LaunchedEffect`, - * so a throw would take down the composition. Only cancellation of the CALLER propagates; a render - * killed by [close] also surfaces as `null` (the caller is still alive — it just has no thumbnail). + * **Never throws for a fetch/render failure** — a fetch/raster failure or a timeout caches the + * rasterizer's placeholder (no retry storm), and the residual case where even the placeholder cannot be + * allocated (a `Bitmap` OOM) degrades to `null` rather than an exception: the caller is a row's + * `LaunchedEffect`, so a throw would take down the composition. Only cancellation of the CALLER + * propagates; a render killed by [close] or superseded by a newer key for the same session also + * surfaces as `null` (the caller is still alive — it just has no thumbnail for this key). */ public suspend fun thumbnail(session: LiveSessionInfo): Bitmap? { val key = ThumbnailKey(session.id, session.lastOutputAt) @@ -83,12 +110,17 @@ public class ThumbnailPipeline( // Re-check under the lock: a render that finished between the fast path and here has already // populated the cache and removed its in-flight entry. cache.get(key)?.let { return it } + // A CLOSED pipeline must not insert: `scope.async` on a cancelled scope never runs the body, so + // the finally that frees the slot never runs and the key would keep a dead Deferred forever + // (every later request awaiting it for null, and `inFlight` growing by every distinct key). + if (!scope.isActive) return null + supersedeOlderRenders(key) inFlight[key] ?: startRender(key).also { inFlight[key] = it } } return try { deferred.await() } catch (cancel: CancellationException) { - // Distinguish "the pipeline was closed under us" (→ no thumbnail) from "OUR caller was + // Distinguish "the render was closed/superseded under us" (→ no thumbnail) from "OUR caller was // cancelled" (→ propagate, so the collector tears down as structured concurrency requires). if (currentCoroutineContext().isActive) null else throw cancel } @@ -99,9 +131,19 @@ public class ThumbnailPipeline( * superseded `lastOutputAt` keys — so the cache holds only what the chooser can still display. The * [LruCache] byte budget already bounds worst-case memory; this returns the memory promptly instead of * waiting for eviction pressure, and keeps dead sessions from occupying the budget at all. + * + * A render still in flight for a session that just disappeared can no longer re-insert its bitmap after + * this prune: dropping the session's [latestKeyBySession] entry closes the publish gate, and both sides + * run under [publishLock] so the check cannot straddle the prune. (A caller that KEEPS asking for a + * pruned session is a different thing and still caches — it asked.) */ public fun retainOnly(live: Collection) { - cache.retainOnly(live.mapTo(HashSet()) { ThumbnailKey(it.id, it.lastOutputAt) }) + val liveIds = live.mapTo(HashSet()) { it.id } + val liveKeys = live.mapTo(HashSet()) { ThumbnailKey(it.id, it.lastOutputAt) } + synchronized(publishLock) { + latestKeyBySession.keys.retainAll(liveIds) + cache.retainOnly(liveKeys) + } } /** Cancel all in-flight renders and release the pipeline scope (call from the owner's teardown). */ @@ -109,48 +151,111 @@ public class ThumbnailPipeline( scope.cancel() } + /** Test-only: live in-flight entries. A non-zero count after [close] would be a leaked key. */ + internal suspend fun inFlightCount(): Int = inFlightMutex.withLock { inFlight.size } + + /** + * Make [key] the session's newest key and abandon any older in-flight render for the SAME session — the + * supersede rule. Caller MUST hold [inFlightMutex]; `Deferred.cancel` does not suspend, so cancelling + * from inside the critical section is safe (the render's own `finally` takes the mutex afterwards). + * + * An out-of-order request for an OLDER screen than one already in flight is left alone: it still renders + * and returns a bitmap to its caller, but it does not become the newest key, so the publish gate keeps + * its result out of the cache instead of overwriting fresher work. + */ + private fun supersedeOlderRenders(key: ThumbnailKey) { + val previous = latestKeyBySession[key.sessionId] + if (previous == key) return + if (previous != null && depictsOlderScreen(key, previous)) return + latestKeyBySession[key.sessionId] = key + previous?.let { inFlight.remove(it)?.cancel() } + } + private fun startRender(key: ThumbnailKey): Deferred = scope.async { var rendered: Bitmap? = null try { rendered = renderGuarded(key.sessionId) rendered } finally { - // ALWAYS release the in-flight slot — including on cancellation (scope closed) and on an - // unexpected throw. A retained entry would poison the key: every later request for it would - // await a dead Deferred forever. NonCancellable so the cleanup still runs while cancelling. + // Release the in-flight slot on EVERY path the body can leave by — normal return, cancellation + // (closed/superseded) and an unexpected throw — because a retained entry would poison the key: + // every later request for it would await a dead Deferred forever. NonCancellable so the cleanup + // still runs while cancelling. (The one path that cannot run this is a scope cancelled between + // the `isActive` check in [thumbnail] and `scope.async` itself, where the body never starts — + // the pipeline is closed by then and this whole map is discarded with it.) withContext(NonCancellable) { inFlightMutex.withLock { // Publish under the same lock, so a concurrent [thumbnail] re-check sees the result the // instant the key stops being in-flight. Success AND placeholder cache identically // (§6.7 no-retry-storm); an unproducible bitmap caches nothing and may be retried. - rendered?.let { cache.put(key, it) } + publish(key, rendered) inFlight.remove(key) } } } } + /** + * Cache [bitmap] under [key] unless the key is no longer the one the UI wants: superseded by newer + * output for the same session, or pruned by [retainOnly] because the session is gone. Caching either + * would spend the LRU budget on a bitmap nothing will ever request again. + */ + private fun publish(key: ThumbnailKey, bitmap: Bitmap?) { + if (bitmap == null) return + synchronized(publishLock) { + if (latestKeyBySession[key.sessionId] != key) return + cache.put(key, bitmap) + } + } + private suspend fun renderGuarded(sessionId: UUID): Bitmap? = try { renderGate.withPermit { - val preview = previewSource.fetch(sessionId) - rasterizer.rasterize(preview) + // The timeout is INSIDE the permit so it budgets the work, not the wait for a permit, and + // the catch/fallback is OUTSIDE it so no placeholder is ever allocated holding a permit. + withTimeout(renderTimeoutMs) { + val preview = previewSource.fetch(sessionId) + rasterizer.rasterize(preview) + } } + } catch (timeout: TimeoutCancellationException) { + // MUST precede the CancellationException branch — a timeout IS one. Rethrowing it would turn a + // slow host into a permanently dead feature: the two permits are the only ones there are, and a + // render is deliberately not cancellable by any caller. + placeholderOrNull() } catch (e: CancellationException) { - throw e // never swallow cooperative cancellation + throw e // never swallow cooperative cancellation (closed pipeline / superseded key) } catch (t: Throwable) { // Any fetch/render failure (network, oversized body, malformed escape) → cache a placeholder // so the broken session isn't retried on every scroll frame (§6.7). If even the placeholder // cannot be allocated, give up with null — never throw at a UI collector. - runCatching { rasterizer.placeholder() }.getOrNull() + placeholderOrNull() } + /** + * The failure placeholder, or `null` when even that cannot be allocated (a `Bitmap` OOM). The failure + * itself is deliberately NOT logged: a preview failure can carry attacker-influenced text, and logcat + * must never see preview bytes (plan §8). + */ + private fun placeholderOrNull(): Bitmap? = runCatching { rasterizer.placeholder() }.getOrNull() + + /** `true` when [key] depicts an older screen than [other] (a smaller server `lastOutputAt`). */ + private fun depictsOlderScreen(key: ThumbnailKey, other: ThumbnailKey): Boolean = + (key.lastOutputAt ?: 0L) < (other.lastOutputAt ?: 0L) + public companion object { /** Plan §6.7: the render/fetch fan-out is capped at 2 concurrent renders. */ public const val MAX_CONCURRENT_RENDERS: Int = 2 /** Default LRU budget for cached thumbnails (bytes). Small — thumbnails are transient UI cache. */ public const val DEFAULT_CACHE_BYTES: Int = 8 * 1024 * 1024 + + /** + * Budget for ONE fetch+render once it holds a permit. Generous next to a small preview GET over the + * shared client (OkHttp's own defaults are 10 s connect / 10 s per-read) — this is the liveness + * backstop for the trickle case those per-socket timeouts cannot catch, not a latency target. + */ + public const val DEFAULT_RENDER_TIMEOUT_MS: Long = 15_000L } } @@ -238,8 +343,8 @@ public class LruBitmapCache(maxBytes: Int) : BitmapCache { /** * Production [PreviewSource] over an [ApiClient] (which sends through the shared mTLS `HttpTransport`, so * tunnel-host previews traverse nginx). `GET /live-sessions/:id/preview` is a READ-ONLY route: it does NOT - * attach, register a client, or touch the session's idle clock. Re-caps the body at 256 KiB client-side — - * defence-in-depth over the server's own cap (plan §6.7 / §4.2). + * attach, register a client, or touch the session's idle clock. Bounds the emulator's input with + * [PreviewCap] (plan §6.7 / §4.2 — read its note on what that bound can and cannot do). * * [api] is a PROVIDER, not a client: resolving it resolves the shared `HttpTransport`, which builds the one * `OkHttpClient` and first-touches the AndroidKeyStore/Tink mTLS material. That must never happen on the @@ -253,15 +358,30 @@ public class ApiPreviewSource(api: () -> ApiClient) : PreviewSource { PreviewCap.cap(apiClient.preview(sessionId)) } -/** Client-side preview-size guard (plan §6.7: cap data at 256 KiB). Pure — JVM-testable. */ +/** + * Client-side bound on how much preview text reaches the off-screen emulator. Pure — JVM-testable. + * + * **What this is NOT:** it is not a defence against an oversized RESPONSE BODY. By the time it runs, the + * transport has already buffered the whole body into a `ByteArray` (`OkHttpHttpTransport` calls + * `body.bytes()`) and `ApiClient.preview` has decoded that into JSON and a `String` — so a rogue multi-MB + * body has already been allocated two or three times over, and capping here allocates one more copy. A real + * body bound has to live in the transport (an OkHttp response-body byte limit), which this class cannot + * reach; until then, the cap here only bounds the *downstream* cost: the VT parse in + * [TerminalEmulator][com.termux.terminal.TerminalEmulator]`.append` and the retained tail string. + */ public object PreviewCap { - /** 256 KiB — mirrors the server's `GET /live-sessions/:id/preview` data cap. */ + /** + * 256 KiB. This is the CLIENT's own ceiling, not a mirror of the server's: `src/config.ts` defaults + * `PREVIEW_BYTES` to **24 KiB** and lets an operator raise it with no upper bound, so this sits ~10× the + * default — big enough never to clip a normally-configured host's tail, small enough to keep the VT + * parse per poll tick bounded whatever a host is configured to (or lies about) sending. + */ public const val MAX_PREVIEW_BYTES: Int = 256 * 1024 /** * Cap [preview]'s UTF-8 byte length at [MAX_PREVIEW_BYTES], keeping the TAIL (the most recent screen) - * so a rogue/huge body can't drive an unbounded emulator append. A tail cut on a UTF-8 boundary at - * worst mangles one leading glyph — harmless for a thumbnail. + * so a huge body can't drive an unbounded emulator append. A tail cut on a UTF-8 boundary at worst + * mangles one leading glyph — harmless for a thumbnail. */ public fun cap(preview: SessionPreview): SessionPreview { val bytes = preview.data.toByteArray(Charsets.UTF_8) diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt index 59eaaea..1782fbd 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt @@ -1,13 +1,18 @@ package wang.yaojia.webterm.wiring import android.graphics.Bitmap +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TerminalOutput +import com.termux.terminal.TerminalSessionClient import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull @@ -26,15 +31,26 @@ import java.util.UUID /** * The [ThumbnailPipeline] policy (plan §6.7), driven at JVM speed with fakes so the cache/concurrency/ - * dedup/failure logic is verified without android.graphics (the pixel raster lives behind the - * [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd). + * dedup/supersede/timeout/failure logic is verified without android.graphics (the pixel raster lives behind + * the [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd). The rasterizer's two + * SIZE decisions are pure ([ThumbnailGrid] / [ThumbnailBudget]) and are tested here too, one of them against + * a real off-screen [TerminalEmulator] (pure Java — the emulator needs no android.graphics). * * Proves: * - an unchanged `(sessionId, lastOutputAt)` ⇒ cache hit, NO re-render; a changed `lastOutputAt` ⇒ render; * - the `Semaphore(2)` caps concurrent fetch+render at 2; * - two concurrent same-key requests SHARE one render (one fetch, one raster, one bitmap); + * - a NEWER key for a session supersedes the older in-flight render and frees its permit; + * - a stalled fetch times out, releases its permit and falls back to the placeholder (liveness); + * - a request after `close()` returns null and leaks no in-flight entry; + * - a render landing after its session was pruned does not re-enter the cache; * - a fetch failure caches the placeholder and is NOT retried on the next request (no hot-loop storm); - * - [PreviewCap] caps the body at 256 KiB, keeping the tail. + * - the off-screen grid never aliases rows, and no server geometry can blow the raster budget; + * - [PreviewCap] caps the emulator's input at 256 KiB, keeping the tail. + * + * **Virtual-time note:** a render is now bounded by [ThumbnailPipeline.DEFAULT_RENDER_TIMEOUT_MS], so + * `advanceUntilIdle()` would advance past that timeout and cancel a deliberately-parked fetch. Tests that + * observe IN-FLIGHT state therefore use `runCurrent()` (run what is ready, advance no virtual time). */ @OptIn(ExperimentalCoroutinesApi::class) class ThumbnailPipelineTest { @@ -103,6 +119,32 @@ class ThumbnailPipelineTest { lastOutputAt = lastOutputAt, ) + /** + * A bare off-screen [TerminalEmulator] — the same construction [CanvasThumbnailRasterizer] uses, minus + * the `Canvas`. Termux's emulator/buffer are pure Java (no android.graphics), so the row-mapping + * geometry is verifiable at JVM speed; only the pixel draw is device-QA. + */ + private fun offScreenEmulator(cols: Int, rows: Int, transcriptRows: Int): TerminalEmulator = + TerminalEmulator( + mockk(relaxed = true), + cols, + rows, + transcriptRows, + mockk(relaxed = true), + ) + + private companion object { + /** Short virtual-time render budget so the timeout test doesn't encode the production value. */ + const val TEST_RENDER_TIMEOUT_MS = 1_000L + + /** Bytes per pixel of `Bitmap.Config.ARGB_8888` (the config the rasterizer allocates). */ + const val ARGB_8888_BYTES = 4 + + /** A representative monospace cell at the rasterizer's 12px default (measured on-device). */ + const val TEMPLATE_CELL_WIDTH_PX = 7 + const val TEMPLATE_CELL_HEIGHT_PX = 14 + } + // ── Tests ───────────────────────────────────────────────────────────────────────────────── @Test @@ -148,7 +190,7 @@ class ThumbnailPipelineTest { ) val jobs = (0 until 5).map { launch { pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L)) } } - advanceUntilIdle() + runCurrent() // no virtual time passes — the parked fetches must not hit the render timeout // Only 2 permits → only 2 fetches in flight; the other 3 are parked on the semaphore. assertEquals(2, source.active) @@ -178,7 +220,7 @@ class ThumbnailPipelineTest { val a = async { pipeline.thumbnail(same) } val b = async { pipeline.thumbnail(same) } - advanceUntilIdle() + runCurrent() assertEquals(1, source.fetchStarts) // deduped: a single in-flight fetch gate.complete(Unit) @@ -262,12 +304,12 @@ class ThumbnailPipelineTest { val s = session(UUID.randomUUID(), lastOutputAt = 3L) val scrolledAway = launch { pipeline.thumbnail(s) } - advanceUntilIdle() + runCurrent() scrolledAway.cancel() - advanceUntilIdle() + runCurrent() val stillVisible = async { pipeline.thumbnail(s) } - advanceUntilIdle() + runCurrent() gate.complete(Unit) advanceUntilIdle() @@ -316,6 +358,237 @@ class ThumbnailPipelineTest { assertEquals(3, raster.rasterizeCount) } + /** + * **Supersede (head-of-line blocking).** `lastOutputAt` advances on every PTY output and the list polls + * every 5 s, so an ACTIVE session mints a new key on every tick. The obsolete render must be abandoned: + * left alone it holds a permit while the row waits for the screen it is actually showing, and its bitmap + * would land in the cache under a key nothing will ever ask for again. + */ + @Test + fun `a newer key supersedes the in-flight render for the same session and frees its permit`() = runTest { + val stall = CompletableDeferred() + val source = FakePreviewSource(block = stall) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + maxConcurrent = 1, // ONE permit: whether the obsolete render blocks the fresh one is the point + ) + val id = UUID.randomUUID() + + val stale = async { pipeline.thumbnail(session(id, lastOutputAt = 100L)) } + runCurrent() + assertEquals(1, source.fetchStarts) + assertEquals(1, source.active) // holds the only permit + + val fresh = async { pipeline.thumbnail(session(id, lastOutputAt = 200L)) } + runCurrent() + assertEquals(2, source.fetchStarts) // the freed permit let the NEWEST key start + assertEquals(1, source.active) // and only one render is alive — the obsolete one was abandoned + + stall.complete(Unit) + advanceUntilIdle() + + assertNull(stale.await()) // the superseded caller gets no bitmap; its screen no longer exists + assertNotNull(fresh.await()) + assertEquals(1, raster.rasterizeCount) // only the newest key was ever rasterised + } + + /** + * **Liveness.** The shared REST client sets no `callTimeout` and its per-socket read timeout is reset by + * every trickled byte, and a render is deliberately not cancellable by any caller — so without a bound + * inside the render, two slow-trickle previews hold both permits forever and NO thumbnail renders again + * until `close()`. The timeout raises `TimeoutCancellationException`, which IS a `CancellationException`: + * it must land in the PLACEHOLDER branch, never the rethrow branch. + */ + @Test + fun `a stalled fetch times out, frees its permit and falls back to the placeholder`() = runTest { + val stall = CompletableDeferred() // never completed: a host that trickles and never finishes + val source = FakePreviewSource(block = stall) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + renderTimeoutMs = TEST_RENDER_TIMEOUT_MS, + ) + + val stalled = (0 until 2).map { async { pipeline.thumbnail(session(UUID.randomUUID(), 1L)) } } + runCurrent() + assertEquals(2, source.active) // both permits held by fetches that will never finish + + val queued = async { pipeline.thumbnail(session(UUID.randomUUID(), 1L)) } + runCurrent() + assertEquals(2, source.fetchStarts) // parked on the semaphore behind the stalled pair + + advanceTimeBy(TEST_RENDER_TIMEOUT_MS + 1) + runCurrent() + + // Both stalled renders gave up with the placeholder (cached ⇒ no retry storm) instead of dying or + // wedging, and — the whole point — the queued key got a permit and ran. + stalled.forEach { assertSame(raster.placeholderBitmap, it.await()) } + assertEquals(3, source.fetchStarts) + + advanceTimeBy(TEST_RENDER_TIMEOUT_MS + 1) + runCurrent() + assertSame(raster.placeholderBitmap, queued.await()) + assertEquals(0, source.active) + pipeline.close() + } + + /** + * `close()` is public and unguarded, and `scope.async` on a cancelled scope never runs the body — so the + * body's `finally` (which frees the in-flight slot) never runs either. Inserting after close would leave + * a dead `Deferred` under that key forever: every later request for it awaits a corpse and gets null, + * and `inFlight` grows by every distinct post-close key. + */ + @Test + fun `a request after close returns null and leaks no in-flight entry`() = runTest { + val source = FakePreviewSource() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = FakeRasterizer(), + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + pipeline.close() + + val afterClose = pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L)) + advanceUntilIdle() + + assertNull(afterClose) + assertEquals(0, source.fetchStarts) // nothing was fetched on a dead pipeline … + assertEquals(0, pipeline.inFlightCount()) // … and no key was left holding a dead Deferred + } + + /** + * The prune is defeatable by a render that finishes AFTER it: re-inserting a killed session's bitmap + * leaves it cached until the row set changes again. The caller that asked still gets its bitmap (it + * asked), but nothing outlives the session in the cache. + */ + @Test + fun `a render landing after its session was pruned is not put back in the cache`() = runTest { + val stall = CompletableDeferred() + val source = FakePreviewSource(block = stall) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val killed = session(UUID.randomUUID(), lastOutputAt = 4L) + + val landing = async { pipeline.thumbnail(killed) } + runCurrent() + assertEquals(1, source.fetchStarts) + + pipeline.retainOnly(emptyList()) // the session was killed while its render was in flight + stall.complete(Unit) + advanceUntilIdle() + assertNotNull(landing.await()) + + // Nothing was cached for the dead session, so asking again re-fetches and re-renders. + val again = async { pipeline.thumbnail(killed) } + advanceUntilIdle() + again.await() + assertEquals(2, source.fetchStarts) + assertEquals(2, raster.rasterizeCount) + } + + // ── Off-screen grid + raster budget (pure; the real emulator is pure Java) ───────────────── + + /** + * **The thumbnail must not draw duplicated rows.** Termux's `TerminalBuffer` keeps + * `mTotalRows = transcriptRows` and maps `externalToInternalRow(row) = (mScreenFirstRow + row) % + * mTotalRows`, so a transcript SHORTER than the screen aliases external row *n* onto *n − mTotalRows*. + * The off-screen emulator used a fixed `TERMINAL_TRANSCRIPT_ROWS_MIN` (100) against rows up to 200, + * so rows 100..199 aliased onto 0..99 — asserted below both ways: the fixed grid is injective, and the + * old geometry provably is not (so this regression cannot quietly come back as "no longer reproducible"). + */ + @Test + fun `the off-screen grid never aliases screen rows onto each other`() { + val grid = ThumbnailGrid.of(cols = 120, rows = 200) + assertEquals(120, grid.cols) + assertEquals(200, grid.rows) + assertTrue( + grid.transcriptRows >= grid.rows, + "transcriptRows=${grid.transcriptRows} must be >= rows=${grid.rows}", + ) + + val fixed = offScreenEmulator(grid.cols, grid.rows, grid.transcriptRows) + val fixedRows = (0 until grid.rows).map { fixed.screen.externalToInternalRow(it) } + assertEquals(grid.rows, fixedRows.toSet().size, "external rows must map 1:1 onto internal rows") + + val aliased = offScreenEmulator(grid.cols, grid.rows, TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN) + val aliasedRows = (0 until grid.rows).map { aliased.screen.externalToInternalRow(it) } + assertTrue( + aliasedRows.toSet().size < grid.rows, + "the aliasing this guards against is no longer reproducible — re-derive the guard", + ) + } + + @Test + fun `the off-screen grid clamps a rogue server geometry and never degenerates`() { + // cols/rows are SERVER-supplied: src/protocol.ts validates a resize only to 1..1000. + val rogue = ThumbnailGrid.of(cols = 1000, rows = 1000) + assertEquals(ThumbnailGrid.MAX_COLS, rogue.cols) + assertEquals(ThumbnailGrid.MAX_ROWS, rogue.rows) + assertTrue(rogue.transcriptRows >= rogue.rows) + + val degenerate = ThumbnailGrid.of(cols = 0, rows = -5) + assertEquals(1, degenerate.cols) + assertEquals(1, degenerate.rows) + assertTrue(degenerate.transcriptRows >= TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN) + } + + /** + * **No server-reported geometry may drive a huge allocation.** A 1000×1000 session (which the protocol + * permits) at a 12px monospace cell was a 2800×2800 ARGB_8888 = 31.4 MB bitmap — ×2 concurrent permits, + * re-run every 5 s poll tick. It degraded safely (an OOM is caught) but invited an LMK kill of the app. + */ + @Test + fun `no server geometry can make the off-screen raster exceed the budget`() { + val budgetBytes = ThumbnailBudget.MAX_WIDTH_PX.toLong() * + ThumbnailBudget.MAX_HEIGHT_PX * ARGB_8888_BYTES + for (cols in intArrayOf(1, 20, 80, 161, 400, 1000)) { + for (rows in intArrayOf(1, 24, 50, 200, 1000)) { + val grid = ThumbnailGrid.of(cols, rows) + val cell = ThumbnailBudget.cellMetrics( + cols = grid.cols, + rows = grid.rows, + templateWidth = TEMPLATE_CELL_WIDTH_PX, + templateHeight = TEMPLATE_CELL_HEIGHT_PX, + ) + val width = grid.cols * cell.width + val height = grid.rows * cell.height + val where = "$cols×$rows → ${width}×$height px (cell ${cell.width}×${cell.height})" + assertTrue(cell.width >= 1 && cell.height >= 1, "zero-sized cell: $where") + assertTrue(width <= ThumbnailBudget.MAX_WIDTH_PX, "width over budget: $where") + assertTrue(height <= ThumbnailBudget.MAX_HEIGHT_PX, "height over budget: $where") + assertTrue(width.toLong() * height * ARGB_8888_BYTES <= budgetBytes, "bytes over budget: $where") + } + } + } + + @Test + fun `the cell shrink preserves the cell aspect and never upscales a small screen`() { + // 80×24 at a 7×14 cell is 560×336 — just over the 480px budget, so both axes take the SAME factor + // (480/560) and the glyph cell keeps its shape instead of being squeezed on one axis. + val shrunk = ThumbnailBudget.cellMetrics(cols = 80, rows = 24, templateWidth = 7, templateHeight = 14) + assertEquals(6, shrunk.width) + assertEquals(12, shrunk.height) + + // Already inside the budget → draw at the font's own cell size, no shrink at all. + val asRendered = ThumbnailBudget.cellMetrics(cols = 40, rows = 10, templateWidth = 7, templateHeight = 14) + assertEquals(7, asRendered.width) + assertEquals(14, asRendered.height) + assertEquals(1f, asRendered.textScale) + } + @Test fun `raster budget downscales an oversized terminal raster and leaves small ones alone`() { // A 161x50 session at 12px monospace rasterises to ~1127x700 = 3.1 MiB ARGB_8888 — three of those @@ -332,6 +605,13 @@ class ThumbnailPipelineTest { val sliver = ThumbnailBudget.scaledSize(width = 4000, height = 3, maxWidth = 480) assertNotNull(sliver) assertEquals(1, sliver!!.height) + + // HEIGHT is bounded too: a tall/narrow raster is already inside the width bound, so a width-only + // budget left it at full size (140×2800 = 1.5 MiB cached for a 96×60dp tile). + val tall = ThumbnailBudget.scaledSize(width = 140, height = 2800, maxWidth = 480, maxHeight = 480) + assertNotNull(tall) + assertEquals(480, tall!!.height) + assertEquals(24, tall.width) // aspect preserved on the tighter axis } /** @@ -366,6 +646,11 @@ class ThumbnailPipelineTest { assertNull(request.headers["Origin"], "the RO preview route must not carry Origin: ${request.headers}") } + /** + * [PreviewCap] bounds what reaches the EMULATOR, not the response body (which the transport has already + * buffered and parsed by then — see its KDoc). 256 KiB is the client's own ceiling: the server's + * `PREVIEW_BYTES` default is 24 KiB and an operator may raise it with no upper bound. + */ @Test fun `preview cap keeps the tail when over 256 KiB and is a no-op under the cap`() { val big = "A".repeat(PreviewCap.MAX_PREVIEW_BYTES + 100) + "TAIL" From c1612d01450e08c2604edb5175367fe07d4e8601 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 12:14:53 +0200 Subject: [PATCH 06/10] build(android): make release buildable and instrumented testing possible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four gaps that made "ship it" and "test it on hardware" impossible. RELEASE SIGNING. assembleRelease was silently producing app-release-unsigned.apk because there was no signingConfigs block at all — the failure mode where you only notice at install time. Credentials now resolve from keystore.properties, then local.properties, then WEBTERM_RELEASE_* env; nothing is created or committed, and keystore.properties.example is the template. The degradation is ENFORCED, not documented. The signingConfig is only created when credentials actually resolve — an empty config is precisely what makes AGP emit an unsigned artifact quietly — and a guard on packageRelease throws with an actionable message, distinguishing "not configured" from "credentials found but the keystore file is missing: ". It hooks the PACKAGE task on purpose: R8 has already run and reported by then, so a developer without a keystore still gets the full shrink result rather than an early abort. Verified: minifyReleaseWithR8 completes, packageRelease fails loudly, and no unsigned APK is written. VERSIONING. Was still the scaffold 1 / "0.1.0". Now 1 / "0.1.0-alpha01", where the suffix is a factual claim about device verification rather than decoration: alpha means device QA is essentially unstarted, beta means the A34/A35 checklist blocks pass on hardware, and plain 0.1.0 means the whole checklist is ticked. versionCode stays 1 because no artifact has ever left the build machine. MINIFICATION. release had isMinifyEnabled = false and an empty rules file — so nothing had ever exercised R8 on this app, and the first release build would have been the experiment. Now minify + resource shrinking, with a deliberately short rules file: the actual artifacts were checked, and kotlinx.serialization, Tink, Hilt, FCM, OkHttp, Compose and CameraX all ship their own consumer rules, so none are re-declared. The termux packages are kept whole because the JitPack artifacts ship no rules and :terminal-view binds to non-API internals — it writes the public TerminalView.mEmulator field and calls TerminalRenderer.getFontWidth()/getFontLineSpacing(). Getting that wrong yields a build that crashes only in production. lintVitalRelease also passes now, the two pre-existing lint ERRORS having been fixed earlier this session. ANDROID TEST ENABLEMENT. :app had no androidTest source set and no instrumentation runner, which is the mechanical reason A34/A35 have no code at all. The runner (Hilt-aware) and dependencies are wired, and a :macrobenchmark module exists. The tests themselves are a separate change. Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :app:assembleBenchmark :macrobenchmark:assembleBenchmark :app:lintVitalRelease koverVerify -> BUILD SUCCESSFUL; 901 JVM tests, 0 failures on a forced re-execution with test-results deleted and --no-build-cache. --- android/DEVICE_QA_CHECKLIST.md | 182 +++++++++++++++++++++--- android/README.md | 168 ++++++++++++++++++++-- android/app/build.gradle.kts | 178 ++++++++++++++++++++++- android/app/proguard-rules.pro | 121 +++++++++++++++- android/gradle/libs.versions.toml | 44 +++++- android/keystore.properties.example | 31 ++++ android/macrobenchmark/build.gradle.kts | 89 ++++++++++++ android/settings.gradle.kts | 7 + 8 files changed, 784 insertions(+), 36 deletions(-) create mode 100644 android/keystore.properties.example create mode 100644 android/macrobenchmark/build.gradle.kts diff --git a/android/DEVICE_QA_CHECKLIST.md b/android/DEVICE_QA_CHECKLIST.md index 060b3b6..1814c66 100644 --- a/android/DEVICE_QA_CHECKLIST.md +++ b/android/DEVICE_QA_CHECKLIST.md @@ -1,19 +1,133 @@ # Android client — device-QA checklist (A36 / plan §7) -Everything below is **device/emulator-only** — it could NOT run in the build environment (no emulator, -no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests, -Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping. +Everything below is **device/emulator-only**: it cannot be proven by the JVM suite. The pure logic +underneath each item IS unit-tested (757 JVM tests across 10 modules, Kover ≥80% on the pure modules), +but the 2026-07-30 audit established the hard lesson this file now exists to enforce — **a green JVM +suite says nothing about whether the app works.** Three crash-on-first-keypress defects and a +cleartext posture that made bare-LAN connection impossible all sat behind 484 passing tests, because +no JVM test instantiates an Android `View` and no JVM test dials a socket. + +**Rules for this file:** tick a box ONLY after observing the behaviour on real hardware (or, where +noted, an emulator) — and say what you observed. Never tick something because the code looks right or +a unit test covers it. Unticked is the honest default. + +--- + +## Observed so far (the only on-device evidence that exists) + +**2026-07-30, minified `benchmark` variant, `webterm` AVD (android-35, arm64, software GPU):** + +- [x] The **R8-minified, resource-shrunk, non-debuggable APK installs and cold-starts.** `am start -W` + → `Status: ok`, `LaunchState: COLD`, `TotalTime: 1085 ms` (software-GPU emulator — not a + meaningful performance number, only proof of a clean start). Process stayed alive; + `logcat -b crash` empty; no `FATAL EXCEPTION`, `NoClassDefFoundError` or `ClassNotFoundException`. + This is the check that R8 keep rules are correct — the class of bug that appears ONLY in release. +- [x] **Cold-start route with no paired host → pairing screen**, rendered correctly under R8 + (`ColdStartPolicy`, A29): title 配对主机, the 手动输入/扫码 segmented control, the + `主机地址(http(s)://…)` field and a disabled 下一步 button. Compose + Material 3 + the design-token + theme + Hilt injection therefore all survive minification. +- [x] **`POST_NOTIFICATIONS` runtime prompt appears** on first launch (API 33+ path, A30). +- [x] **ML Kit component registration** no longer fails under R8 (`ComponentDiscovery` clean). Before + the `ComponentRegistrar` keep rule it logged + `NoSuchMethodException: com.google.mlkit.common.internal.CommonComponentRegistrar. []`, + which would have silently disabled **QR barcode scanning in release builds only**. + +**Expected/benign in the same run:** `W FirebaseApp: Default FirebaseApp failed to initialize because +no default options were found` — there is no `google-services.json` yet (see the deploy artifacts +below). A `System UI isn't responding` dialog also appeared; that is the emulator's own `com.android.systemui` +under software GPU, not this app (our process logged no ANR and stayed `topResumedActivity`). + +**Everything else in this file is unobserved.** Notably: no real WebTerm host has ever been connected +to from Android, no terminal bytes have ever been rendered on a device, and no push has ever arrived. + +--- ## Deploy artifacts to provide first (not built here) +- [ ] **Release signing credentials.** `:app:assembleRelease` deliberately FAILS at packaging until they + exist — it will never emit an unsigned APK. Provide `webterm.release.{storeFile,storePassword,keyAlias,keyPassword}` + in `android/keystore.properties` (see `keystore.properties.example`; confirm it is gitignored + first) or `android/local.properties`, or the `WEBTERM_RELEASE_*` env vars. Keep the keystore + out of the repo — a lost signing key cannot be recovered. - [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its absence with `runCatching`, so the app runs without it; push just won't register). - [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately omitted it — applying it without the json fails the build). - [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert - SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the - `FCM_*` env group (service-account key path + project id). + SHA-256 (for the verified App Link `autoVerify`). Read it with + `keytool -list -v -keystore -alias | grep SHA256`. Server-side: run `A33` + `src/push/fcm.ts` with the `FCM_*` env group (service-account key path + project id). + +--- + +## FIRST PRIORITY — confirm the 2026-07-30 blocker fixes on a device + +These were the defects that made the app unusable on hardware. All are fixed and JVM-verified; **none +has been exercised on a device.** Until this block is ticked, nothing further in this file is worth +running, because every one of these sits on the path to the terminal. + +- [ ] **Typing does not crash.** Soft-keyboard keys, Enter, Backspace via the installed + `WebTermTerminalViewClient` / `RemoteTerminalHostView` IME contract. (Was: NPE on the first + keypress — stock `TerminalView.onKeyDown` dereferences `mClient`, then `mTermSession`.) +- [ ] **Swipe-scrolling inside an alternate-screen TUI does not crash.** Claude Code IS an + alternate-screen TUI, so this is ordinary use, not an edge case: drag vertically while a Claude + session is running. Also test the fling and, on a device with a mouse/trackpad, wheel scroll + (`onGenericMotionEvent`). (Was: `doScroll` → `handleKeyCode` → `mTermSession` NPE.) +- [ ] **A password manager does not crash the app.** Open the app with an autofill service enabled and + focus the terminal. (Was: unguarded `autofill()` while the view advertised itself autofillable; + the subtree is now excluded from the autofill structure.) +- [ ] **`resize` actually reaches the PTY.** A full-screen TUI fills the screen instead of rendering + into an 80x24 box, and reflows on rotation. (Was: `updateSize` had zero call sites.) Confirm the + cols×rows the server reports match what `TerminalResizeDriver` computed from + `TerminalRenderer.getFontWidth()/getFontLineSpacing()`. +- [ ] **Bare-LAN `ws://` connects.** Pair with `http://:3000` and attach. (Was: + `network_security_config.xml` was a stub denying all cleartext while `HostEndpoint` derives + `ws://` from `http://`.) +- [ ] **The tunnel domain is still TLS-only.** `http://.terminal.yaojia.wang` must be refused + (the one hostname with `cleartextTrafficPermitted="false"`), and `OkHttpClientFactory` must + refuse a plaintext dial to a non-private host even if the config would allow it. +- [ ] **§6.4 device-switch reclaim.** Attach the same session from a phone and a tablet; the device you + last touched drives the PTY size (latest-writer-wins) and reclaims full screen on + pane-show/window-focus without a re-attach. +- [ ] **`WEBTERM_TOKEN` gate end-to-end** against a server started WITH the token: REST + the WS + upgrade both carry the cookie, it survives a process restart (sealed with Tink AEAD under an + AndroidKeyStore key), and a **seal failure persists nothing** rather than falling back to + plaintext. Also verify the cookie never appears in `logcat`. + ⚠️ **Blocked until the cookie jar + cipher are installed in `:app`'s DI** — until then the gate + is inert at runtime no matter what the server does. +- [ ] **Newly-wired surfaces that had ZERO call sites before this session** — each needs a first-ever + look: session-list **preview thumbnails** (`ThumbnailPipeline`), **quick-reply chips + palette** + (`QuickReply`), and the tablet **pointer context menu** (`PointerContextMenu`). +- [ ] **Newly-decoding server frames.** The `queue` frame ("N queued" badge) and `status.preview` were + both being dropped as undecodable — the latter meant remote approvals were **blind**. Confirm a + real gate now shows the command being approved, and that a malformed preview does not cost the + whole frame (the gate must still appear). + +--- + +## KNOWN FEATURE GAP — copy-out via text selection is unavailable (deliberate) + +**Not a bug to be fixed by re-enabling stock selection.** At the pinned Termux v0.118.0, the stock +`ActionMode`'s own handlers dereference the `TerminalSession` this app deliberately does not have +(`TextSelectionCursorController$1.onActionItemClicked`, offsets 89-100 for Copy and 126-136 for Paste). +`TerminalSession` is `final` and its constructor forks a local process over JNI — exactly what a remote +terminal must not do (plan §6.1). So making the selection UI reachable would put a guaranteed NPE on +the **Copy** button, which is worse than not offering it. Long press is therefore consumed and +selection never starts. + +**The real fix** (not built, tracked): a first-party selection layer — hit-test to a cell, own the +selection anchors, read the text straight out of `TerminalBuffer`, and write it with `ClipboardManager`. +Paste-in is unaffected and already works. + +- [ ] Confirm on a device that long press is inert and does **not** show a broken Copy affordance. + +--- + +## A34 — instrumented E2E vs a real `npm start` host + +Infrastructure now EXISTS (`:app` has androidTest deps, a Hilt-aware `testInstrumentationRunner`, and +Espresso + Compose-UI-test on the classpath) but **the tests themselves are not written**. See +`android/README.md` → "Instrumented tests" for the one runner class that must be authored first. -## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device) - [ ] `attach → attached → output` round-trip timing. - [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay. - [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy. @@ -22,15 +136,19 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h - [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS. ## A35 — one macrobenchmark / Espresso happy path + +The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumenting `:app`'s +`benchmark` variant out-of-process); it has **no sources yet**. + - [ ] pair → attach → type → approve, on a real device. +- [ ] cold-start timing on real hardware (`StartupTimingMetric`). The 1085 ms above is a software-GPU + emulator number and must not be quoted as a baseline. ## Terminal (A16/A17/A21) - [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8 WebView fallback ONLY if fidelity diverges — not expected). -- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap. -- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are - package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); resize parity vs - web/iOS on the same device sizes (R5). +- [ ] IME/CJK composition; http/https link tap. (Selection→clipboard is the gap above, not a test.) +- [ ] **real font-metric → grid resize** and resize parity vs web/iOS on the same device sizes (R5). - [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background → generation bump → fresh emulator replays. @@ -47,22 +165,27 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h - [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth success; cancel/error/no-enrolled-auth → no POST (fail-safe). - [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency). -- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals - on rotation / new host / removal. +- [ ] token registers to every paired host + self-heals on rotation / new host / removal. +- [ ] **In a MINIFIED build specifically** — FCM and ML Kit both discover components reflectively, so + re-verify push registration and QR scanning on the `benchmark`/release variant, not just debug. ## Pairing / cert / storage (A19/A27/A11/A12) -- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. +- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. **Run this on a minified + variant too** (see the `ComponentRegistrar` note above — this path is the one R8 already broke once). - [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't bypass); public host explicit-ack. - [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove. +- [ ] **Cert summary does not crash** — see the `javax.naming` defect under "Known gaps"; the issuer-CN / + expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY. - [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited. ## Nav / deep links / adaptive (A32/A26/A29) - [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the - right destination; invalid UUID ignored. -- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens. + right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and + `assetlinks.json`.) +- [ ] continue-last-session banner re-opens. (The no-host → pairing half is observed above.) - [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` + `ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600). @@ -85,11 +208,32 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ; **push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited). -## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md) +--- + +## Known gaps (tracked, non-blocking for QA but must be routed to an owner) + +- [ ] **`:client-tls` uses `javax.naming.ldap.LdapName`, which does not exist on Android.** Found by R8 + (`Missing class javax.naming.ldap.LdapName ... referenced from CertificateSummaryReader.commonName`). + On-device that call throws `NoClassDefFoundError`, an **`Error`** — so it slips straight through the + function's `catch (_: Exception)` guard and propagates out of `summarize()`. The module is pure-JVM + and its unit tests run on a JDK where `LdapName` DOES exist, which is exactly why the green suite + never caught it. Effect: the device-cert screen's issuer-CN/expiry summary (A27) crashes. + Fix: hand-parse the RFC2253 DN (or at minimum catch `Throwable`). The `-dontwarn` in + `proguard-rules.pro` only unblocks R8; it changes nothing at runtime. +- [ ] **The auth cookie jar + cipher are not installed in `:app`'s DI**, so the `WEBTERM_TOKEN` gate is + inert at runtime even though every piece is implemented and tested. - [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't carry the sessionId; the gate is still visible in the terminal). MEDIUM. - [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link. - [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked). -- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView` - is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to - `:app` and delete the shim. +- [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a + `dataExtractionRules` `` xml resource. Until then a D2D migration could copy the + sealed auth cookie to another handset. +- [ ] AGP's lint crashes with `FirDeclaration was not found for class KtProperty, fir is null` on + `ThumbnailPipeline.kt` when run under `--rerun-tasks`. `lintVitalRelease` passes normally and from + a cold lint state; this is a lint/K2 internal bug (lint says so itself), not an app defect. Do not + use `--rerun-tasks` in a release gate. + +> RESOLVED since the last revision: the A21 reflection shim (`getMethod("getTerminalView")`) is **gone** — +> `RemoteTerminalHostView` now holds the stock `com.termux.view.TerminalView` directly, so there is no +> reflective getter left to break under R8 and no keep rule is needed for one. diff --git a/android/README.md b/android/README.md index 0a0a431..67e1db1 100644 --- a/android/README.md +++ b/android/README.md @@ -9,6 +9,13 @@ This directory is a **Gradle multi-module** project. The module set mirrors the SPM package set and inherits its rule: *dependencies only flow down; nothing points upward* (ARCHITECTURE §1). +> **State, honestly:** the app builds, minifies, signs (given a keystore) and cold-starts +> to the pairing screen on an emulator. It has **never talked to a real WebTerm host from +> a device**, and almost nothing in +> [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md) is ticked. Version is +> `0.1.0-alpha01` for exactly that reason. Read that checklist before calling anything +> here done. + ## Build environment (SDK installed — all modules build) The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework @@ -19,10 +26,12 @@ against SDK 35/36. `:client-tls`, `:test-support`, `:transport-okhttp`. - **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):** `:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`. +- **Instrumented-only:** `:macrobenchmark` (`com.android.test`; assembles here, runs only + on a device/emulator). Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`; `google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate: -`./gradlew test :app:assembleDebug koverVerify`. +`./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest koverVerify`. ## Module map (mirror of the iOS SPM packages — plan §3) @@ -37,10 +46,15 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools | HostRegistry | `:host-registry` | Android (DataStore) | ✅ built | | SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built | | App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built | +| — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources | -> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport` -> impls, JVM) is owned by task **A7** and will be added then. The iOS -> `URLSession*Transport`s consolidate into it (plan §3 framing note). +`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a +`com.android.test` module — a **separate APK** that drives `:app` out of process via +UiAutomator, which is the only way startup/frame timings are real. It therefore cannot +violate "dependencies only flow down": nothing depends on it, and it reaches `:app` +through `targetProjectPath`, not a `project()` dependency. It instruments `:app`'s +`benchmark` variant (see "Build types" below). The benchmark sources themselves are not +written yet. ### Dependency graph (arrows = "depends on") @@ -82,15 +96,124 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`). ```bash # Use the committed wrapper for everything. -./gradlew help # sanity: the build configures -./gradlew projects # lists the 5 pure modules -./gradlew build # compile all pure modules -./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK) +./gradlew help # sanity: the build configures +./gradlew projects # lists every module +./gradlew test # JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK) +./gradlew :app:assembleDebug # the installable debug APK +./gradlew koverVerify # the ≥80% gate on the pure modules + +# Release path (needs a keystore — see "Release signing") +./gradlew :app:assembleRelease # R8 + resource shrinking + signing +./gradlew :app:lintVitalRelease # the release-blocking lint subset; must be clean + +# Minified build WITHOUT a keystore — the practical way to test R8 keep rules +./gradlew :app:assembleBenchmark # same shrinking as release, debug-signed → installable ``` > Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`, > `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data, > small focused files — same discipline as the rest of the repo. +> +> **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug +> (`FirDeclaration was not found for class KtProperty, fir is null`, on +> `ThumbnailPipeline.kt`). `lintVitalRelease` passes normally and from a cold lint state. + +## Build types + +| Type | Minified | Shrunk res | Debuggable | Signed with | Purpose | +|---|---|---|---|---|---| +| `debug` | no | no | yes | debug key | development; stable applicationId for deep-link tests (A32) | +| `release` | **yes** | **yes** | no | **release key (required)** | the shipping artifact | +| `benchmark` | **yes** | **yes** | no | debug key | `initWith(release)` + `isProfileable`; what `:macrobenchmark` measures, and the only way to exercise R8 without a keystore | + +`benchmark` exists because a benchmark must measure the code that actually ships. It sets +`isProfileable = true`, which makes AGP inject `` into +the merged manifest — done there rather than in `AndroidManifest.xml` so the shipping +manifest carries no benchmark-only tag. + +## Versioning + +Current: `versionCode = 1`, `versionName = "0.1.0-alpha01"`. + +- **`versionCode`** is a plain monotonic counter — **+1 for every artifact handed to anyone** + (a Play track, an APK sent to a tester, an archived benchmark build). It is deliberately + NOT derived from `versionName`; keeping them independent is what lets a hotfix ship + without renumbering. It is still `1` because no artifact has ever left the build machine; + the first distributed build takes `2`. +- **`versionName`** is `-alphaNN`. The client tracks the server's `0.1.x` line + (root `package.json` is `0.1.0`). The `-alpha01` suffix is a factual claim about device + verification, not marketing: + - `-alphaNN` — builds, minifies, JVM-tested; **device QA essentially unstarted**. ← today + - `-betaNN` — the A34/A35 blocks of `DEVICE_QA_CHECKLIST.md` pass on real hardware. + - `0.1.0` — the whole checklist is ticked. + + Bump the suffix on any user-visible change while still in alpha; move to `0.1.1-alphaNN` + only when the server line moves. + +## Release signing + +There is **no keystore in this repository and there must never be one.** Credentials are +read at configuration time from the first of these that has them: + +1. `android/keystore.properties` — the conventional path. Copy + [`keystore.properties.example`](keystore.properties.example). + ⚠️ **Confirm `keystore.properties` is listed in `android/.gitignore` before creating it.** + The existing rules cover `*.jks` / `*.keystore` / `*.p12` but not this filename. +2. `android/local.properties` — already gitignored, so it needs no new rule. Same four keys. +3. `WEBTERM_RELEASE_STORE_FILE` / `_STORE_PASSWORD` / `_KEY_ALIAS` / `_KEY_PASSWORD` — for CI. + +Keys: `webterm.release.storeFile` (absolute, or relative to `android/`), +`.storePassword`, `.keyAlias`, `.keyPassword`. + +**Degradation contract:** with no credentials, `debug`, `benchmark`, `assembleDebugAndroidTest` +and every test still work, and `:app:assembleRelease` **fails at `packageRelease`** with +instructions. It fails at *packaging*, deliberately after R8, so an unconfigured machine +still gets a full, verifiable R8 run. It never silently emits `app-release-unsigned.apk` +(which is exactly what it used to do). + +**Archive `app/build/outputs/mapping/release/mapping.txt` with every distributed artifact** — +release builds keep line numbers but obfuscate names, so without the mapping file a crash +report cannot be retraced. + +### R8 / keep rules + +`app/proguard-rules.pro` is deliberately short and every rule is justified in place; most +of the stack (kotlinx.serialization, Hilt, Tink, Firebase, OkHttp, Compose, CameraX) +ships its own consumer rules and must not be re-declared. Two rules are load-bearing and +were both derived from **observed** failures, not guesswork: + +- `com.termux.**` is kept whole, because `:terminal-view` binds to non-API internals of the + pinned v0.118.0 (the public `TerminalView.mEmulator` field, `TerminalRenderer.getFontWidth()`) + and the JitPack artifacts ship no consumer rules. +- `implements com.google.firebase.components.ComponentRegistrar { (); }`, because + without it R8 strips the reflectively-invoked constructors of ML Kit's registrars and + **QR scanning silently stops working in release builds only**. + +Before adding a rule, read the merged configuration R8 actually used: +`app/build/outputs/mapping//configuration.txt`. To validate rules, install the +`benchmark` APK and exercise the feature — a wrong keep rule is invisible in debug. + +## Instrumented tests + +`:app` has an androidTest classpath (androidx.test + Espresso + Compose UI test + +`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to +`wang.yaojia.webterm.HiltTestRunner`. + +⚠️ **That runner class does not exist yet** — it is the first file the A34 author must write, +into `app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt`: + +```kotlin +class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application = + super.newApplication(cl, HiltTestApplication::class.java.name, context) +} +``` + +A custom runner is **required**, not stylistic: `newApplication` is the only hook that can +replace the app-under-test's `Application` with the generated `HiltTestApplication`. Setting +`android:name` in the androidTest manifest cannot do it — that manifest is merged into the +*test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the +class (the runner name is just a manifest value); an actual instrumentation *run* needs it. ## Android SDK setup (proven working) @@ -127,5 +250,32 @@ AGP library module compiled against SDK 35 and produced an AAR): `android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER `kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable. -To add more SDK pieces later (e.g. an emulator image for instrumented tests): +- **A `com.android.test` module cannot use a versioned plugin alias.** The root build + script already puts AGP on the classpath via the `android.library`/`android.application` + aliases, so a third versioned request for the same artifact fails with *"plugin is already + on the classpath with an unknown version"*. `:macrobenchmark` therefore applies + `id("com.android.test")` bare — same as `:terminal-view` with `com.android.library`. The + version is still pinned once, as `agp` in the catalog. + +## Emulator (installed) + +An AVD named **`webterm`** exists (android-35, arm64, software GPU) and is what produced the +on-device evidence recorded in [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md): + +```bash +export ANDROID_HOME=/usr/local/share/android-commandlinetools +$ANDROID_HOME/emulator/emulator -avd webterm -gpu swiftshader_indirect & +$ANDROID_HOME/platform-tools/adb devices -l + +# validate the MINIFIED build (this is what catches bad keep rules) +./gradlew :app:assembleBenchmark +adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk +adb logcat -c && adb shell am start -W -n wang.yaojia.webterm/.MainActivity +adb logcat -d --pid=$(adb shell pidof wang.yaojia.webterm) | grep -E "FATAL|NoSuchMethod|NoClassDefFound" +``` + +Note it is a **software-GPU** emulator: it is fine for crash/keep-rule/route validation and +useless for performance numbers. Real macrobenchmark figures need physical hardware. + +To add more SDK pieces later: `sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8010a60..b244242 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -10,6 +10,8 @@ // com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android // ───────────────────────────────────────────────────────────────────────────── +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) @@ -17,6 +19,83 @@ plugins { alias(libs.plugins.hilt) } +// ───────────────────────────────────────────────────────────────────────────── +// Release signing credentials — NEVER committed. +// +// Looked up in this order, first non-blank wins: +// 1. `android/keystore.properties` (the conventional path; see keystore.properties.example) +// 2. `android/local.properties` (already gitignored — the zero-setup local option) +// 3. `WEBTERM_RELEASE_*` env vars (CI) +// +// DEGRADATION CONTRACT: absent credentials leave debug (and the `benchmark` variant, +// which signs with the debug key) fully working, and make the RELEASE package task fail +// with RELEASE_SIGNING_HELP — never a silent `app-release-unsigned.apk`. +// ───────────────────────────────────────────────────────────────────────────── +val signingPropertyFiles = listOf("keystore.properties", "local.properties") + +val signingProperties = Properties().apply { + // Later files must not override earlier ones, so load in reverse and let the + // higher-priority file overwrite. + signingPropertyFiles.reversed() + .map(rootProject::file) + .filter(File::exists) + .forEach { file -> file.inputStream().use(::load) } +} + +fun signingCredential(propertyKey: String, envKey: String): String? = + (signingProperties.getProperty(propertyKey) ?: System.getenv(envKey))?.takeIf(String::isNotBlank) + +val releaseStoreFile = signingCredential("webterm.release.storeFile", "WEBTERM_RELEASE_STORE_FILE") +val releaseStorePassword = signingCredential("webterm.release.storePassword", "WEBTERM_RELEASE_STORE_PASSWORD") +val releaseKeyAlias = signingCredential("webterm.release.keyAlias", "WEBTERM_RELEASE_KEY_ALIAS") +val releaseKeyPassword = signingCredential("webterm.release.keyPassword", "WEBTERM_RELEASE_KEY_PASSWORD") + +// `storeFile` may be absolute or relative to `android/` — rootProject.file handles both. +val releaseKeystore = releaseStoreFile?.let(rootProject::file) + +val hasAllReleaseCredentials = + releaseStoreFile != null && + releaseStorePassword != null && + releaseKeyAlias != null && + releaseKeyPassword != null + +val isReleaseSigningConfigured = hasAllReleaseCredentials && releaseKeystore?.exists() == true + +// Two distinct failures deserve two distinct messages: "you have not set this up" and +// "you set it up but pointed it at a file that isn't there" have completely different fixes. +val MISSING_KEYSTORE_HELP = """ + |:app release signing credentials were found, but the keystore file does not exist: + | ${releaseKeystore?.absolutePath} + | + |Fix `webterm.release.storeFile` (absolute, or relative to the `android/` directory), or + |point WEBTERM_RELEASE_STORE_FILE at the real file. Nothing was signed, and no unsigned + |APK was emitted. +""".trimMargin() + +val UNCONFIGURED_SIGNING_HELP = """ + |:app release signing is NOT configured — refusing to emit an unsigned release artifact. + | + |Provide the four credentials in ONE of these places (first match wins): + | 1. android/keystore.properties — copy android/keystore.properties.example. + | MAKE SURE `keystore.properties` is in android/.gitignore first. + | 2. android/local.properties — already gitignored; zero extra setup. + | 3. env: WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI) + | + |Keys (property form): + | webterm.release.storeFile= + | webterm.release.storePassword=<...> + | webterm.release.keyAlias=<...> + | webterm.release.keyPassword=<...> + | + |No keystore yet? Generate one OUTSIDE the repo: + | keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \ + | -alias webterm -keyalg RSA -keysize 4096 -validity 10000 + |Then record its SHA-256 in the App Links assetlinks.json (plan §8). + | + |Unaffected by this failure: `:app:assembleDebug`, `:app:assembleBenchmark`, + |`:app:assembleDebugAndroidTest`, every unit test, and R8 itself (which already ran). +""".trimMargin() + android { namespace = "wang.yaojia.webterm" // compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for @@ -28,24 +107,87 @@ android { applicationId = "wang.yaojia.webterm" minSdk = 29 targetSdk = 35 + // ── Versioning policy ──────────────────────────────────────────────── + // versionCode: a plain monotonic counter, +1 for EVERY artifact handed to + // anyone (Play track, internal APK, benchmark run that gets archived). + // Never derived from versionName — decoupling them is what lets a hotfix + // ship without renumbering. This is still 1 because no artifact has ever + // left this machine; the first distributed build takes 2. + // versionName: `-alphaNN`. The Android client tracks the server's + // 0.1.x line (root package.json is 0.1.0). `-alpha01` is the honest state: + // the P0+P1 surface from ANDROID_CLIENT_PLAN §1 is implemented and JVM-tested, + // but NOTHING in DEVICE_QA_CHECKLIST.md has been signed off on real hardware. + // Drop to `-beta01` when the checklist's A34/A35 blocks pass on a device, and + // to plain `0.1.0` when the whole checklist is ticked. versionCode = 1 - versionName = "0.1.0" + versionName = "0.1.0-alpha01" + + // Hilt instrumented testing REQUIRES a custom runner: AndroidJUnitRunner is the + // only hook that can swap the app-under-test's Application for the generated + // `HiltTestApplication` (the androidTest manifest cannot — it is merged into the + // TEST apk, not into the app under test). See android/README.md → "Instrumented tests". + testInstrumentationRunner = "wang.yaojia.webterm.HiltTestRunner" } buildFeatures { compose = true } + signingConfigs { + // Populated ONLY when credentials were found; an empty config would make AGP + // fall back to emitting `app-release-unsigned.apk`, which is exactly the silent + // failure this block exists to prevent (the release buildType leaves + // `signingConfig` null instead, and the guard task below fails the build). + if (isReleaseSigningConfigured) { + create("release") { + storeFile = rootProject.file(releaseStoreFile!!) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + // v1 (JAR signing) is only needed below API 24 and is the weakest scheme — + // off. v2/v3 are left enabled and apksigner picks what the minSdk range + // actually requires: verified output for minSdk 29 is v1=false, v2=false, + // v3=true (every device that can install this understands v3, so v2 is + // redundant). Do not read v2=false as a misconfiguration. + enableV1Signing = false + enableV2Signing = true + enableV3Signing = true + } + } + } + buildTypes { debug { // No applicationId suffix — keep it stable for deep-link testing (A32). } release { - isMinifyEnabled = false + isMinifyEnabled = true + // Resource shrinking requires minification; it is what strips the unused + // CameraX/ML-Kit/Firebase resources the 56 MB unminified APK carried. + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + signingConfig = signingConfigs.findByName("release") + } + // `benchmark` — what :macrobenchmark (A35) measures against. It must match the + // shipping variant in everything that affects performance (non-debuggable, R8'd, + // resource-shrunk) while needing NO release keystore, so it signs with the debug + // key. It is therefore also the variant that proves the R8 pipeline end-to-end + // (`:app:assembleBenchmark` produces a real, installable, minified APK). + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + // Library modules only publish debug/release; resolve `benchmark` to release. + matchingFallbacks += listOf("release") + isDebuggable = false + // Injects into the merged manifest, which + // macrobenchmark needs to read traces on API 29-30 (implicit from API 31). + // Done here BECAUSE AndroidManifest.xml must not carry a benchmark-only tag. + isProfileable = true + isMinifyEnabled = true + isShrinkResources = true } } @@ -114,9 +256,41 @@ dependencies { testImplementation(libs.bundles.unit.test) testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6) testRuntimeOnly(libs.junit.platform.launcher) + + // ── Instrumented tests (androidTest — device/emulator only; plan §7) ────────── + // Homes A34 (E2E against a real `npm start` host) and the Espresso/Compose half of + // A35. JUnit4 only: AndroidJUnitRunner cannot drive the JUnit5 platform. + androidTestImplementation(libs.bundles.android.instrumented.test) + // Compose UI assertions (gate banner / plan sheet / reconnect-banner precedence / + // session rows / continue-last banner). BOM-managed, same BOM as main. + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + // Supplies the empty host Activity that `createComposeRule()` launches into. Must be + // debugImplementation (it contributes a manifest entry to the app under test). + debugImplementation(libs.androidx.compose.ui.test.manifest) + // Hilt instrumented testing: HiltAndroidRule + the generated HiltTestApplication. + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.compiler) } // Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules. +// Instrumented (androidTest) tasks are NOT of type Test, so they keep JUnit4. tasks.withType().configureEach { useJUnitPlatform() } + +// ───────────────────────────────────────────────────────────────────────────── +// Release-signing guard. +// +// Hooked as a doFirst on the PACKAGE task (not as a dependency) on purpose: packaging +// runs after dexing, so R8/resource-shrinking have fully completed and been reported by +// the time this fires. An unconfigured machine therefore still gets a real, verifiable +// R8 run — it just cannot produce a distributable artifact. +// ───────────────────────────────────────────────────────────────────────────── +if (!isReleaseSigningConfigured) { + val help = if (hasAllReleaseCredentials) MISSING_KEYSTORE_HELP else UNCONFIGURED_SIGNING_HELP + tasks.matching { it.name == "packageRelease" || it.name == "packageReleaseBundle" } + .configureEach { + doFirst { throw GradleException(help) } + } +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 45f9aa2..acb163c 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,4 +1,117 @@ -# WebTerm :app — R8/ProGuard rules. -# Release is not minified yet (isMinifyEnabled = false); this file exists so the -# release buildType's proguardFiles(...) reference resolves. Real keep-rules for -# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later. +# ───────────────────────────────────────────────────────────────────────────── +# WebTerm :app — R8 keep rules (release + benchmark; both isMinifyEnabled = true). +# +# DISCIPLINE: every rule below is justified, and rules that turned out to be +# UNNECESSARY are recorded as such rather than added "just in case". A superfluous +# -keep silently costs size and hides real reachability problems; a MISSING one +# crashes only in production. Both were checked against the merged configuration +# R8 actually used, which is dumped to: +# app/build/outputs/mapping//configuration.txt +# Read that file before adding anything here — most of our stack already ships its +# own consumer rules, and duplicating them is how this file rots. +# +# ALREADY COVERED BY EMBEDDED CONSUMER RULES — do NOT re-declare here: +# · kotlinx.serialization — kotlinx-serialization-core-jvm ships +# META-INF/com.android.tools/r8/kotlinx-serialization-{common,r8}.pro, which keeps +# @Serializable classes' `Companion` fields, `serializer(...)`, object `INSTANCE`, +# the `$$serializer.descriptor` field, and RuntimeVisibleAnnotations (the R8 +# full-mode fix). Verified present in 1.9.0. Our @Serializable models live in +# :wire-protocol / :api-client; the rules are global, so they are covered. +# · Hilt / Dagger — hilt-android + dagger AARs ship proguard.txt; all injection is +# generated code with compile-time references, no runtime reflection to preserve. +# · Tink — tink-android ships META-INF/proguard/protobuf.pro, keeping on +# subclasses of the shaded protobuf GeneratedMessageLite (the reflective lite +# runtime). Tink's key managers are registered explicitly by AeadConfig.register(), +# not discovered reflectively, so nothing else needs keeping. +# · FCM — firebase-messaging ships consumer rules, AND `.push.FcmService`, +# `.push.DenyBroadcastReceiver`, `.push.AllowTrampolineActivity`, `.MainActivity` +# and `.WebTermApp` are all declared in AndroidManifest.xml, for which AGP +# auto-generates keep rules (build/intermediates/aapt_proguard_file/.../aapt_rules.txt). +# Manifest-declared components therefore need no rule here — ever. +# · OkHttp / Okio, Compose, CameraX, ML Kit barcode, DataStore, navigation-compose, +# biometric — all ship their own consumer rules. +# · JNI — the default proguard-android-optimize.txt already contains +# `-keepclasseswithmembernames class * { native ; }`, which covers the +# Termux emulator's native entry points even though we never fork a subprocess. +# ───────────────────────────────────────────────────────────────────────────── + + +# ── Termux terminal emulator + view (the ONE thing that genuinely needs a rule) ── +# +# Why this is not "just in case": +# 1. The JitPack artifacts (com.github.termux.termux-app:terminal-{emulator,view}, +# v0.118.0) ship NO consumer rules at all — nobody upstream has reasoned about R8 +# for these modules, because Termux itself ships unminified. +# 2. :terminal-view deliberately binds to NON-API internals of the pinned version +# (plan §6.1 — TerminalView is `public final`, so we fork rather than subclass): +# · RemoteTerminalHostView writes and reads the public field +# `com.termux.view.TerminalView.mEmulator` directly (installEmulator / +# the TerminalViewClient callbacks / computeVerticalScrollRange). +# · TerminalResizeDriver calls `TerminalRenderer.getFontWidth()` and +# `getFontLineSpacing()` — public accessors verified with `javap -p`, but not +# part of any documented API surface. +# · TerminalScrollGesture reads `mEmulator` and drives KeyHandler. +# Field/method RENAMING alone would survive that (R8 rewrites both sides), but R8 +# full mode also does class merging, member inlining and access relaxation across +# a library whose invariants we are already stretching. A wrong outcome here is a +# crash in the app's single core surface — the terminal — visible ONLY in release. +# 3. The cost is bounded and small: two Apache-2.0 modules, ~120 KB of classes. +# +# So: keep them whole. Revisit only with a device-verified minified build in hand. +-keep class com.termux.terminal.** { *; } +-keep class com.termux.view.** { *; } + + +# ── ML Kit / Firebase component discovery (reflective no-arg constructors) ─────── +# +# OBSERVED, not speculative. Installing the minified `benchmark` APK on an API-35 +# emulator and cold-starting it produced, with no rule here: +# +# W ComponentDiscovery: Invalid component registrar. +# Could not instantiate com.google.mlkit.common.internal.CommonComponentRegistrar +# ... at com.google.mlkit.common.internal.MlKitInitProvider.onCreate +# Caused by: java.lang.NoSuchMethodException: +# com.google.mlkit.common.internal.CommonComponentRegistrar. [] +# (and the same for com.google.mlkit.vision.common.internal.VisionCommonRegistrar) +# +# Firebase's ComponentDiscovery instantiates registrars named in by +# `getDeclaredConstructor()`. The class names survive, but R8 removed the no-arg +# constructors because nothing in the app calls them. The failure is a WARNING, not a +# crash: the app starts fine and the ML Kit component graph is simply never registered, +# so **QR pairing (A19 barcode scanning) silently stops working in minified builds +# only**. Exactly the release-only breakage this file has to prevent. +# +# `();` (no access modifier) matches any visibility, which is what +# getDeclaredConstructor needs. Scoped to registrars, so it keeps ~a dozen tiny classes. +-keep class * implements com.google.firebase.components.ComponentRegistrar { + (); +} + + +# ── javax.naming does not exist on Android ────────────────────────────────────── +# +# R8 refuses to run without these two, so they are load-bearing for ANY minified build. +# They are NOT a benign suppression — read this before deleting them: +# +# :client-tls CertificateSummary.kt uses `javax.naming.ldap.LdapName` to pull the CN +# out of an RFC2253 DN. That package is JDK-only; it is absent from android.jar and +# from the ART bootclasspath. So on a real device `CertificateSummaryReader.commonName` +# throws NoClassDefFoundError, which is an Error and therefore slips straight through +# that function's `catch (_: Exception)` guard. +# +# The module is pure-JVM and its unit tests run on a real JDK where LdapName DOES +# exist, which is why the green JVM suite never caught it. Tracked as a handoff to +# :client-tls's owner (hand-parse the DN, or catch Throwable). This -dontwarn only +# stops R8 from failing the build over an unresolvable reference; it does not and +# cannot fix the device behaviour. +-dontwarn javax.naming.ldap.LdapName +-dontwarn javax.naming.ldap.Rdn + + +# ── Crash triage ──────────────────────────────────────────────────────────────── +# Keep line numbers so release stack traces are usable, and rename the source-file +# attribute to a single constant so no original filenames leak in the APK. Retrace +# release traces with app/build/outputs/mapping/release/mapping.txt (ARCHIVE IT with +# every distributed artifact — without it a crash report is unreadable). +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a179dfa..4d73b9d 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,5 +1,6 @@ ## Version catalog — single source of truth for plugin & dependency versions. -## Pure JVM modules only (this env has NO Android SDK). See android/README.md. +## Covers BOTH the pure Kotlin/JVM modules and the Android-framework modules (the +## SDK is installed on this machine — see android/README.md "Android SDK setup"). ## Kotlin pinned to 2.3.21 to match the Kotlin embedded in Gradle 9.6.1 (best ## compatibility with the Kotlin Gradle Plugin on Gradle 9.6). @@ -52,10 +53,18 @@ datastore = "1.1.1" # listenablefuture conflict dep is unnecessary here). termux = "v0.118.0" # androidx-test — lets androidTest (instrumented) sources COMPILE here; they only -# RUN on a device (no emulator installed → deferred to device QA, plan §7). +# RUN on a device/emulator (plan §7 — the A34 E2E suite needs a real `npm start` host). androidxTestExtJunit = "1.2.1" androidxTestCore = "1.6.1" androidxTestRunner = "1.6.2" +androidxTestRules = "1.6.1" +# Espresso + UiAutomator drive the A35 happy path (in-process Compose assertions and +# out-of-process device interaction respectively). Pinned to the androidx-test 1.6 line. +espresso = "3.6.1" +uiautomator = "2.3.0" +# Macrobenchmark (A35) — startup/frame timing measured from a SEPARATE `com.android.test` +# module against a non-debuggable, minified `benchmark` variant of :app. +benchmark = "1.3.4" # QR pairing (A19): CameraX preview/analysis + on-device ML Kit barcode scanning. camerax = "1.4.1" mlkitBarcode = "17.3.0" @@ -127,6 +136,20 @@ termux-terminal-emulator = { module = "com.github.termux.termux-app:terminal-emu androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExtJunit" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } +androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +# Compose UI test — BOM-managed (do NOT pin). `ui-test-manifest` is a debugImplementation: +# it supplies the empty activity `createComposeRule()` launches into. +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +# Hilt instrumented testing — supplies `HiltAndroidRule` + the generated +# `dagger.hilt.android.testing.HiltTestApplication` that :app's custom runner installs. +# REQUIRES kspAndroidTest(hilt-compiler); without the processor HiltTestApplication is +# never generated and the runner will not compile. +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +# Macrobenchmark (A35) — used by the :macrobenchmark `com.android.test` module only. +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" } # QR pairing camera (A19) androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } @@ -148,6 +171,17 @@ unit-test = ["junit-jupiter", "kotlinx-coroutines-test", "turbine", "mockk"] # CameraX (A19 QR pairing) — preview + analysis + PreviewView. camerax = ["androidx-camera-core", "androidx-camera-camera2", "androidx-camera-lifecycle", "androidx-camera-view"] +# Instrumented (androidTest) foundation — wired into :app's androidTestImplementation. +# JUnit4 arrives transitively via androidx.test.ext:junit (AndroidJUnitRunner is JUnit4-only; +# the JVM unit tests stay on the JUnit5 platform, which instrumented tasks never touch). +android-instrumented-test = [ + "androidx-test-ext-junit", + "androidx-test-core", + "androidx-test-runner", + "androidx-test-rules", + "androidx-test-espresso-core", +] + # Compose UI + Material 3 (+ Adaptive) — wired into :app implementation. All # BOM-managed; add ui-tooling separately as a debugImplementation. compose = [ @@ -167,6 +201,12 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" } +# NOTE: there is deliberately NO `android-test` alias for :macrobenchmark's +# `com.android.test` plugin. The two aliases above put AGP on the build classpath with +# an "unknown version", so a THIRD versioned alias for the same artifact is rejected +# ("plugin is already on the classpath with an unknown version"). :macrobenchmark +# therefore applies `id("com.android.test")` bare and inherits the `agp` version above — +# exactly the pattern :terminal-view uses for `id("com.android.library")`. kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } # NOTE: AGP 9+ has BUILT-IN Kotlin support — Android modules apply only the # android plugin, NOT a separate org.jetbrains.kotlin.android (it errors out). diff --git a/android/keystore.properties.example b/android/keystore.properties.example new file mode 100644 index 0000000..abcb3ec --- /dev/null +++ b/android/keystore.properties.example @@ -0,0 +1,31 @@ +# TEMPLATE ONLY — copy to `keystore.properties` and fill in. NEVER commit the copy. +# +# cp keystore.properties.example keystore.properties +# +# BEFORE you create the real file, make sure `keystore.properties` is listed in +# android/.gitignore. If it is not there yet, use `local.properties` instead — that +# path is ALREADY gitignored and :app reads the same four keys from it. +# +# Resolution order in app/build.gradle.kts (first non-blank wins): +# 1. android/keystore.properties +# 2. android/local.properties +# 3. WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI) +# +# With none of them set, debug + benchmark builds work normally and +# `:app:assembleRelease` FAILS at packaging with instructions — it never emits an +# unsigned APK. +# +# Generate a keystore OUTSIDE the repository: +# keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \ +# -alias webterm -keyalg RSA -keysize 4096 -validity 10000 +# +# Then read its SHA-256 (needed for the App Links assetlinks.json — plan §8): +# keytool -list -v -keystore ~/.webterm/webterm-release.jks -alias webterm | grep SHA256 + +# Path to the keystore. Absolute, or relative to the `android/` directory. +# Keep the keystore itself out of the repo (android/.gitignore blocks *.jks / *.keystore, +# but out-of-tree is the safer habit — a lost signing key cannot be recovered). +webterm.release.storeFile=/absolute/path/to/webterm-release.jks +webterm.release.storePassword=CHANGE_ME +webterm.release.keyAlias=webterm +webterm.release.keyPassword=CHANGE_ME diff --git a/android/macrobenchmark/build.gradle.kts b/android/macrobenchmark/build.gradle.kts new file mode 100644 index 0000000..7d0d732 --- /dev/null +++ b/android/macrobenchmark/build.gradle.kts @@ -0,0 +1,89 @@ +// ───────────────────────────────────────────────────────────────────────────── +// :macrobenchmark (A35) — the macrobenchmark harness module. +// +// `com.android.test`, NOT `com.android.library`: macrobenchmark must drive the app +// OUT of process (its own APK, its own process, UiAutomator across the boundary) so +// that measured startup/frame timings are the real ones and not perturbed by the +// test framework sharing the app's process. +// +// It instruments :app's `benchmark` variant — non-debuggable, R8'd and +// resource-shrunk like release, but signed with the debug key so no release keystore +// is needed (see app/build.gradle.kts → buildTypes.benchmark). +// +// This module currently has NO sources: the benchmark itself (pair → attach → type → +// approve, plus a cold-start timing) is the next task's deliverable and belongs in +// src/main/kotlin/. The module exists so that work is a matter of adding a file. +// +// Run (device/emulator required — `./gradlew :app:assembleDebug` style CI cannot): +// ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest +// +// AGP 9 has built-in Kotlin → apply ONLY the android plugin (no kotlin.android). +// The plugin id is applied BARE (no catalog alias / no version): the root build script +// already puts AGP on the classpath via the android.library + android.application +// aliases, and Gradle rejects a third versioned request for the same plugin artifact +// ("already on the classpath with an unknown version"). The version is still pinned in +// one place — `agp` in gradle/libs.versions.toml. Same pattern as :terminal-view. +// ───────────────────────────────────────────────────────────────────────────── + +plugins { + id("com.android.test") +} + +android { + namespace = "wang.yaojia.webterm.macrobenchmark" + compileSdk = 36 + + defaultConfig { + // Macrobenchmark itself needs API 23+; matching :app's 29 keeps the variant + // matrix honest (a benchmark on an SDK :app cannot run on is meaningless). + minSdk = 29 + targetSdk = 35 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + // The only variant this module is built in. `isDebuggable = true` applies to the + // TEST apk (never to :app) — required so the harness can be instrumented at all. + create("benchmark") { + isDebuggable = true + signingConfig = getByName("debug").signingConfig + // :app publishes debug/release/benchmark; libraries publish debug/release. + matchingFallbacks += listOf("release") + } + } + + targetProjectPath = ":app" + + // The harness runs in its OWN process and reaches the app only through UiAutomator / + // shell, never by linking against its classes. Without this, AGP's + // `checkTestedAppObfuscationBenchmark` refuses to build a non-minified test module + // against a minified app — a check that is correct for Espresso-style tests (which do + // link app symbols) and a false positive for macrobenchmark. + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +kotlin { + jvmToolchain(17) +} + +// Only the `benchmark` variant is meaningful; disable the stock debug/release ones so +// `./gradlew build` does not try to assemble a harness against a variant of :app that +// does not exist. +androidComponents { + beforeVariants { variant -> + variant.enable = variant.buildType == "benchmark" + } +} + +dependencies { + // A `com.android.test` module's `main` source set IS its test code — deps go in + // implementation, not androidTestImplementation. + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.uiautomator) + implementation(libs.androidx.benchmark.macro.junit4) +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 46ba5ca..e7b3182 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -51,3 +51,10 @@ include(":app") // A13 — libs.plugins.android.application + c include(":terminal-view") // A16 — android-library + Termux terminal-emulator/-view (JitPack, Apache-2.0) include(":host-registry") // A12 — android-library + DataStore (Host list / last-session) include(":client-tls-android") // A11 — android-library + AndroidKeyStore import + Tink AEAD + +// ── Instrumented-only module (no production code, never on :app's classpath) ───── +// :macrobenchmark (A35) — a `com.android.test` module that drives :app's `benchmark` +// variant OUT of process via UiAutomator. Separate APK by construction, so it cannot +// violate the "dependencies only flow down" rule: nothing depends on it, and it depends +// on :app only through `targetProjectPath`, not a project() dependency. +include(":macrobenchmark") // A35 — libs.plugins.android.test + benchmark-macro-junit4 From 538c8ebf34beff73c94dc3480a0c4b54fa486bc1 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 13:25:42 +0200 Subject: [PATCH 07/10] fix(android): move emulator mutation to the render thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instrumented suite was run on a device for the first time and the app died on the first test: ArrayIndexOutOfBoundsException: length=31; index=31 at com.termux.terminal.TerminalRow.getStyle at com.termux.view.TerminalRenderer.render at com.termux.view.TerminalView.onDraw PROGRESS_ANDROID.md had classified exactly this as "Accepted (not a defect): steady-state append runs concurrently with the UI-thread onDraw ... torn read self-corrects next frame". Both halves of that were wrong. It is not cosmetic. TerminalRenderer.render caches its column bound once from TerminalEmulator.mColumns and then indexes TerminalRow.getStyle(column) into a raw long[] sized when that row was allocated. TerminalEmulator.resize publishes the new mColumns BEFORE resizeScreen() reallocates the rows, so a draw landing in that window reads the new bound against old-width rows. The first out-of-range column is exactly oldColumns, which is why the exception was always length == index — the consistency was a deterministic signature of a grow, not a rare interleaving. A shrink is harmless, which is probably why "self-corrects" looked plausible. TerminalBuffer.resize is non-atomic in several more ways, and TerminalRow.setChar swapping mText for a larger array is the same hazard on the append path. Nor does it match upstream. Upstream's background reader only fills a ByteQueue; TerminalSession$MainThreadHandler is what calls append, and TerminalView.updateSize resizes — both on the main thread. Upstream has no concurrent reader at all. There is also no lock to lean on: monitorenter appears nowhere in TerminalEmulator, TerminalBuffer, TerminalRow or TerminalRenderer. The race was dormant until this session. updateSize had no call sites, so mColumns never changed after bind and there was never a width mismatch to tear on. Making resize work is what made this reachable — the same lesson as the rest of this pass: the green suite could not see any of it because no JVM test instantiates a View. Fix: every emulator mutation now runs on the render thread, from inside the same single confined consumer. §6.2 is intact — still one writer draining one ordered channel, suspending across the main hop, so a resize and an append still cannot interleave and submission order still holds. They are now also serialised against onDraw, because one Handler work item cannot run inside another. Resize still reaches the wire and the §6.4 forced re-assert still bypasses the dedup. Chunking becomes load-bearing rather than incidental: each 4 KiB slice is its own main-thread work item, so a multi-MB ring replay interleaves with frames and input instead of blocking behind one long call — which is what §6.2's no-ANR requirement actually needs, rather than "runs off the main thread". Gating the child's dispatchDraw was considered and rejected: it would have to cover appends too, leaving a choice between blanking the terminal and blocking the UI thread on a full reflow. A read/write lock was rejected because append calls back out through TerminalOutput (title, bell, DA/DSR reply), so holding a write lock across it would bury a deadlock invariant in app-level callbacks. The KDoc that made the false claims now states the mechanism with offsets, and RemoteTerminalHostView records why dispatchDraw is deliberately not gated. Verified: the device crash is reproduced as a JVM test first (red), so this regression is now guarded without a device. ./gradlew test :app:assembleDebug koverVerify -> 903 tests, 0 failures. On the emulator the previously-crashing test now passes and 5 of 6 alternate-screen scroll tests pass. --- .../terminalview/RemoteTerminalHostView.kt | 15 ++ .../terminalview/RemoteTerminalSession.kt | 129 ++++++---- .../EmulatorMutationThreadTest.kt | 239 ++++++++++++++++++ 3 files changed, 339 insertions(+), 44 deletions(-) create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/EmulatorMutationThreadTest.kt diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt index 93fcfe7..472d53f 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt @@ -76,6 +76,21 @@ import kotlin.math.roundToInt * 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. * + * ### Why `dispatchDraw` is NOT gated (the fourth 2026-07-30 crash, and where it was actually fixed) + * The stock `onDraw` → `TerminalRenderer.render` walk is not defensive: it caches its column bound from + * `TerminalEmulator.mColumns` and then indexes each row's `long[]` style array, which is sized to whatever + * `TerminalBuffer.mColumns` was when that row was allocated. A regrid landing mid-frame therefore killed + * the process with `ArrayIndexOutOfBoundsException: length=31; index=31`, and there is no lock to take — + * `monitorenter` appears nowhere in the pinned emulator or renderer. This frame owns the child's + * `dispatchDraw`, so suppressing the draw during a mutation was one available fix; it was NOT the one + * chosen, because it would have to cover every mutation (appends swap `TerminalRow.mText` too) and would + * mean either blanking the terminal or blocking the UI thread on a 10 000-row reflow. + * + * Instead [RemoteTerminalSession] mutates the emulator **on this thread** — the main thread — exactly as + * upstream Termux does, so a mutation and a draw are two main-thread work items and cannot interleave at + * all. That is the invariant this view depends on: **nothing may mutate `stockView.mEmulator`'s state off + * the main thread.** If that is ever broken, gating here becomes necessary again. + * * 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]) 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 a5df859..e0a3608 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 @@ -14,7 +14,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield import java.util.ArrayDeque import java.util.concurrent.Executors import wang.yaojia.webterm.wire.ClientMessage @@ -30,29 +29,62 @@ import wang.yaojia.webterm.wire.ClientMessage * `:wire-protocol` ([ClientMessage]) — the `SessionEvent → ByteArray` decode is A21's, not here * (boundary note §3). * - * ### Single-writer confinement (plan §6.2 mustFix) - * `TerminalEmulator`/`TerminalBuffer` is single-writer, read by the renderer on the UI thread. Every - * mutation (append, resize, pending flush) is serialized onto ONE confined dispatcher via an ordered - * command [Channel] — mirroring Termux's background reader thread — so a multi-MB ring replay never - * blocks the UI thread (ANR) and never races the on-screen draw. Only [onScreenUpdated] is posted to - * [mainDispatcher]. Ordering is exactly submission order (FIFO channel + one consumer). + * ### Single-writer confinement, and where the writer actually runs (plan §6.2 — CORRECTED 2026-07-30) + * There is exactly ONE writer: an ordered command [Channel] drained by a single consumer on + * [confinedDispatcher], so every mutation (append, resize, pending flush) happens in strict submission + * order and never concurrently with another mutation. What CHANGED is where the mutation itself executes: + * the consumer hops to [mainDispatcher] for the emulator call and nothing else. + * + * **Why it has to.** The stock renderer reads [TerminalBuffer] on the UI thread during `onDraw`, and the + * pinned Termux artifacts contain **no synchronisation whatsoever** — `monitorenter` appears nowhere in + * `TerminalEmulator`, `TerminalBuffer`, `TerminalRow` or `TerminalRenderer`. Upstream Termux is safe not + * by locking but by **thread identity**: its background reader only fills a `ByteQueue`, and + * `TerminalSession$MainThreadHandler.handleMessage` is what calls `TerminalEmulator.append` — on the main + * thread — while `TerminalView.updateSize()` resizes from the main thread too. So upstream has no + * concurrent reader at all, and this fork's earlier "single writer racing the UI-thread renderer, torn + * reads self-correct next frame" note was wrong twice over: it did not match upstream, and the tear is + * not cosmetic. `TerminalRenderer.render` caches its column bound from `TerminalEmulator.mColumns` + * (offsets 14-18) and then indexes `TerminalRow.getStyle(column)` — a raw `long[]` sized when the row was + * allocated — while `TerminalEmulator.resize` publishes the new `mColumns` (offsets 97-106) BEFORE + * `resizeScreen()` reallocates the rows (offset 160). A draw landing in that window throws + * `ArrayIndexOutOfBoundsException: length=oldColumns; index=oldColumns` on the UI thread: a process kill, + * observed on a real emulator. `TerminalBuffer.resize` is non-atomic in five more ways besides (it + * republishes `mLines`, then `mTotalRows`/`mScreenRows`/`mScreenFirstRow`/`mActiveTranscriptRows`), one of + * which fails as `IllegalArgumentException` out of `externalToInternalRow`; and `TerminalRow.setChar` + * swaps `mText` for a larger array, which is the same hazard on the append path. + * + * **Why this still honours §6.2's mustFix (no ANR on a multi-MB replay).** §6.2 forbids a *synchronous + * multi-MB* append on the main thread. The append is still [APPEND_CHUNK_BYTES]-chunked, and each chunk is + * its OWN main-thread work item, so the looper regains control between chunks: input and frames interleave + * with the replay instead of waiting behind it. No single work item is long, which is what "does not ANR" + * actually requires — not "runs on another thread". + * + * **Deadlock safety.** [confinedDispatcher] is never held while waiting on anything except the main hop + * itself, and the main thread never waits on the writer (there is no lock in either direction). The + * consumer is the only caller of the emulator, so an emulator callback re-entering this class (title, + * bell, DA/DSR reply via [termOutput]) runs on the main thread inside the hop and must therefore stay + * non-blocking — [engineSend] is documented as safe to call from any thread and must not block. * * The emulator is **not** exposed to the stock renderer until the initial [pendingOutput] flush has - * COMPLETED: the Bind command flushes the queue on the confined thread first, THEN posts a single Main - * action that publishes `mEmulator` to the view AND fires the first screen update — so the renderer - * never reads [TerminalBuffer] while the confined thread is still appending at the bind moment (§6.2). + * COMPLETED: the Bind command flushes the queue first, THEN publishes `mEmulator` to the view and fires + * the first screen update, so the renderer never sees a half-replayed buffer at the bind moment. * One malformed/hostile escape byte cannot freeze the terminal either: [consumeCommands] survives a * per-command throw and keeps draining (only cancellation propagates). * * @param engineSend the outbound sink — every [ClientMessage] produced here (Input/Resize) is handed to - * it. A21 bridges it to the engine's ordered send pump (§6.3). Must be safe to call from any thread. + * it. A21 bridges it to the engine's ordered send pump (§6.3). Must be safe to call from any thread, + * and must not block: it can be invoked from inside an emulator callback on the main thread. * @param onTitleChanged raw OSC 0/2 title delegate. Passed through UNsanitized — :terminal-view has no - * `:session-core` edge, so :app wires this to `TitleSanitizer` (plan §6.5 / boundary note §3). - * @param onBell OSC/ctrl-G bell delegate (the view may buzz/flash). - * @param appendDispatcher the confined single-writer dispatcher (default: a private single-thread - * executor). Injected as a `StandardTestDispatcher` in unit tests so the seam drives under virtual time. - * @param mainDispatcher where [onScreenUpdated] is posted (default `Main.immediate`; a test dispatcher - * in unit tests so no real main looper is needed). + * `:session-core` edge, so :app wires this to `TitleSanitizer` (plan §6.5 / boundary note §3). Called on + * [mainDispatcher] from inside `append`; must not block. + * @param onBell OSC/ctrl-G bell delegate (the view may buzz/flash). Same threading note as + * [onTitleChanged]. + * @param appendDispatcher the confined dispatcher that SEQUENCES commands and slices appends (default: a + * private single-thread executor). It no longer touches emulator state — [mainDispatcher] does. Injected + * as a `StandardTestDispatcher` in unit tests so the seam drives under virtual time. + * @param mainDispatcher the renderer's own thread: where every emulator mutation and every + * [onScreenUpdated] runs (default `Main.immediate`; a test dispatcher in unit tests so no real main + * looper is needed). */ public class RemoteTerminalSession( private val engineSend: (ClientMessage) -> Unit, @@ -64,7 +96,10 @@ public class RemoteTerminalSession( appendDispatcher: CoroutineDispatcher? = null, private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, ) { - /** Emulator-originated output sink + title/clipboard/bell delegate. Runs on the confined thread. */ + /** + * Emulator-originated output sink + title/clipboard/bell delegate. Every method here is called from + * inside `TerminalEmulator.append`, i.e. on [mainDispatcher] — so none of them may block. + */ private val termOutput: TerminalOutput = object : TerminalOutput() { override fun write(data: ByteArray?, offset: Int, count: Int) { if (data == null || count <= 0) return @@ -89,7 +124,7 @@ public class RemoteTerminalSession( public val emulator: TerminalEmulator = TerminalEmulator(termOutput, initialCols, initialRows, transcriptRows, NoOpTerminalSessionClient()) - // ── Confinement plumbing (all mutable state below is touched ONLY on the confined thread) ──────── + // ── Sequencing plumbing (all mutable state below is touched ONLY on the confined consumer) ─────── private val ownedExecutor = if (appendDispatcher == null) { Executors.newSingleThreadExecutor { r -> Thread(r, "webterm-term-append").apply { isDaemon = true } } } else { @@ -115,7 +150,7 @@ public class RemoteTerminalSession( /** * Feed remote WS output into the emulator. Never blocks the caller (the command channel is * UNLIMITED) and never mutates the emulator on the caller's thread — the bytes are appended on the - * confined dispatcher. Output arriving before [attachView]/[bind] is queued and replayed in order + * renderer's own thread, chunked. Output arriving before [attachView]/[bind] is queued and replayed in order * (§6.2); the `ESC[0m` replay prefix is passed through, never stripped. */ public fun feedRemote(bytes: ByteArray) { @@ -126,8 +161,8 @@ public class RemoteTerminalSession( /** * Latest-writer-wins resize (§6.4). Resolves the emulator's local buffer reflow AND emits a * `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. + * [lastSentDims] and are positive. The dedup runs on the confined consumer so a resize never races an + * append; the reflow itself runs on [mainDispatcher] so it never races a draw either (`onResize`). * * @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 @@ -165,11 +200,11 @@ public class RemoteTerminalSession( // ── View binding (pendingOutput flush) ─────────────────────────────────────────────────────────── /** - * Bind the on-screen view. The emulator is NOT published to the stock renderer synchronously here - * (on Main): the confined thread may still be flushing [pendingOutput] into the buffer, and the - * renderer reads it during draw — publishing now would race that flush (§6.2). Instead the publish - * is routed through the command channel so it lands on [mainDispatcher] only AFTER the initial flush - * completes, together with the first screen update. The sink then invalidates the view on every feed. + * Bind the on-screen view. The emulator is NOT published to the stock renderer synchronously here: + * [pendingOutput] may still hold an unflushed ring replay, and a renderer that can see the buffer + * mid-replay paints a half-restored screen. So the publish is routed through the command channel and + * lands only AFTER the initial flush completes, together with the first screen update. The sink then + * invalidates the view on every feed. */ public fun attachView(view: RemoteTerminalView) { view.session = this @@ -182,10 +217,10 @@ public class RemoteTerminalSession( } /** - * Testable bind seam: register the [onScreenUpdated] sink, flush pending output on the confined - * thread, THEN publish the emulator ([publishEmulator]) + fire the first screen update on the main - * dispatcher. Production goes through [attachView] (which supplies [publishEmulator]); unit tests - * call this directly (no android View needed) and default [publishEmulator] to a no-op. + * Testable bind seam: register the [onScreenUpdated] sink, flush pending output, THEN publish the + * emulator ([publishEmulator]) + fire the first screen update. Production goes through [attachView] + * (which supplies [publishEmulator]); unit tests call this directly (no android View needed) and + * default [publishEmulator] to a no-op. */ internal fun bind(publishEmulator: () -> Unit = {}, onScreenUpdated: () -> Unit) { commands.trySend(TerminalCommand.Bind(publishEmulator, onScreenUpdated)) @@ -198,7 +233,7 @@ public class RemoteTerminalSession( ownedExecutor?.shutdownNow() } - // ── The single confined consumer (one writer to the emulator) ──────────────────────────────────── + // ── The single confined consumer (the one writer; it mutates on [mainDispatcher]) ───────────────── private suspend fun consumeCommands() { for (command in commands) { @@ -239,10 +274,9 @@ public class RemoteTerminalSession( while (pendingOutput.isNotEmpty()) { appendChunked(pendingOutput.removeFirst()) } - // The flush is now COMPLETE on this confined thread. Only now do we hand the emulator to the - // stock renderer AND fire the first screen update — both on the main dispatcher, in one action - // ordered strictly after the flush. The renderer therefore never reads TerminalBuffer while the - // confined thread is still appending at the bind moment (§6.2 single-writer-vs-UI-read). + // The flush is now COMPLETE. Only now do we hand the emulator to the stock renderer AND fire the + // first screen update — one main-thread action ordered strictly after the last append, so the + // renderer's first frame sees the whole replay rather than a partially restored screen. withContext(mainDispatcher) { publishEmulator() onScreenUpdated() @@ -254,17 +288,25 @@ public class RemoteTerminalSession( // `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) + // The reflow rewrote the on-screen cells, so the repaint rides along in the SAME main-thread work + // item as the mutation: no frame can be drawn between them, and without a repaint the user keeps + // looking at the old wrap points until the next byte arrives (on an idle Claude session, minutes). + val notifyScreenUpdated = screenUpdateSink + withContext(mainDispatcher) { + emulator.resize(cols, rows) // local buffer reflow — on the renderer's own thread + notifyScreenUpdated?.invoke() + } 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() } /** - * Append [bytes] to the emulator in [APPEND_CHUNK_BYTES] slices, yielding between chunks so a - * multi-MB replay stays cooperative (cancellable, and never monopolizes the confined thread). + * Append [bytes] to the emulator in [APPEND_CHUNK_BYTES] slices, one main-thread work item per slice. + * + * The slicing is what keeps a multi-MB ring replay off the ANR path (§6.2): the main looper regains + * control between chunks, so frames and input interleave with the replay rather than queueing behind + * it. It also keeps the loop cooperatively cancellable — `withContext` is a suspension point. + * * UTF-8 continuation state is buffered inside the emulator across `append` calls, so slicing at an * arbitrary byte boundary never corrupts a multi-byte character. */ @@ -273,9 +315,8 @@ public class RemoteTerminalSession( while (offset < bytes.size) { val end = minOf(offset + APPEND_CHUNK_BYTES, bytes.size) val chunk = if (offset == 0 && end == bytes.size) bytes else bytes.copyOfRange(offset, end) - emulator.append(chunk, chunk.size) + withContext(mainDispatcher) { emulator.append(chunk, chunk.size) } offset = end - if (offset < bytes.size) yield() } } diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/EmulatorMutationThreadTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/EmulatorMutationThreadTest.kt new file mode 100644 index 0000000..9937591 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/EmulatorMutationThreadTest.kt @@ -0,0 +1,239 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.TerminalEmulator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.ClientMessage +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +/** + * REGRESSION for the fourth 2026-07-30 crash: a regrid on the confined writer thread tearing the + * `TerminalBuffer` out from under the stock renderer's UI-thread read. + * + * ## What was observed on a device (AVD `webterm`, android-35) + * ``` + * FATAL EXCEPTION: main + * java.lang.ArrayIndexOutOfBoundsException: length=31; index=31 + * at com.termux.terminal.TerminalRow.getStyle(TerminalRow.java:244) + * at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:104) + * at com.termux.view.TerminalView.onDraw(TerminalView.java:921) + * ``` + * + * ## Why `length == index` is a signature, not a coincidence + * `TerminalRenderer.render` caches its column bound ONCE, from `TerminalEmulator.mColumns` + * (`terminal-view-v0.118.0.aar`, `render` offsets 14-18), then walks `0 until columns` calling + * `TerminalRow.getStyle(column)` (offset 346) — a raw `long[]` index whose array was sized to the + * `TerminalBuffer.mColumns` in force when the row was allocated (`TerminalRow.` offsets 21-25: + * `mStyle = new long[columns]`). `TerminalEmulator.resize` publishes its own `mColumns` FIRST (offsets + * 97-106) and only then calls `resizeScreen()` → `TerminalBuffer.resize` (offset 160), which is where the + * rows are actually reallocated. So a reader that starts between those two points sees the NEW bound + * against OLD rows, and the very first out-of-range column is exactly `oldColumns` — hence + * `index == length`, every time, for any grow. + * + * ## Why these two tests can live on the JVM + * `TerminalEmulator`/`TerminalBuffer`/`TerminalRow` are pure Java. [renderLikeRead] reproduces the + * renderer's *indexing contract* — cache the bound, then index rows — so the race is reachable without a + * `Canvas`, a `View` or a device. `TerminalResizeDrawRaceRegressionTest` (instrumented) covers the same + * defect through the real `onDraw`; this pair is the fast gate that keeps it from coming back. + */ +class EmulatorMutationThreadTest { + + /** + * The crash itself: hammer alternating grids through [RemoteTerminalSession.updateSize] while a + * reader on the *render* thread walks the buffer the way `TerminalRenderer.render` does. + * + * Before the fix this throws within a handful of iterations (`AIOOBE length=31; index=31` from + * `getStyle`, or `IllegalArgumentException extRow=…, mScreenRows=…` from `externalToInternalRow` — + * the same regrid observed at a different offset). After it, the mutation and the read are both main + * -thread work items and can no longer interleave at all. + * + * The reader deliberately does NOT touch `TerminalRow.mText`: that array is reallocated by + * `setChar` (offsets 283-320 / 430-471) so it has its own, much rarer, tearing shape which would make + * this test flaky for a reason that is not the regrid. This test is about the regrid. + */ + @Test + fun `a regrid never tears the buffer out from under a reader on the render thread`() { + val renderThread = Executors.newSingleThreadExecutor { r -> Thread(r, RENDER_THREAD) } + val writerThread = Executors.newSingleThreadExecutor { r -> Thread(r, WRITER_THREAD) } + val resizesOnTheWire = AtomicInteger(0) + val session = RemoteTerminalSession( + engineSend = { if (it is ClientMessage.Resize) resizesOnTheWire.incrementAndGet() }, + transcriptRows = TEST_TRANSCRIPT_ROWS, + appendDispatcher = writerThread.asCoroutineDispatcher(), + mainDispatcher = renderThread.asCoroutineDispatcher(), + ) + session.bind {} + + val failure = AtomicReference(null) + val readsCompleted = AtomicInteger(0) + val stop = AtomicBoolean(false) + val reader = readerOnRenderThread(renderThread, session.emulator, stop, failure, readsCompleted) + + try { + repeat(REGRID_ITERATIONS) { iteration -> + val cols = if (iteration % 2 == 0) NARROW_COLS else WIDE_COLS + session.updateSize(cols = cols, rows = ROWS) + session.feedRemote(SAMPLE_OUTPUT) + } + awaitTrue("all $REGRID_ITERATIONS regrids to reach the wire") { + resizesOnTheWire.get() >= REGRID_ITERATIONS || failure.get() != null + } + } finally { + stop.set(true) + runBlocking { reader.join() } + session.close() + renderThread.shutdownNow() + writerThread.shutdownNow() + } + + assertNull( + failure.get(), + "the render thread saw a half-applied regrid — TerminalBuffer was mutated off the render " + + "thread (${failure.get()})", + ) + assertTrue( + readsCompleted.get() > 0, + "the reader never ran, so this test proved nothing (completed=${readsCompleted.get()})", + ) + assertEquals( + REGRID_ITERATIONS, + resizesOnTheWire.get(), + "every real grid change must still reach the server — the fix must not disable resize", + ) + } + + /** + * The invariant behind the fix, pinned positively: the emulator is mutated on the RENDER thread. + * + * `TerminalEmulator.append` calls `TerminalOutput.titleChanged` synchronously while parsing an + * `OSC 0 ; … BEL`, so the title delegate is a free probe for "which thread is inside `append`". If a + * future edit moves the append back onto the confined writer, this fails immediately and by name, + * rather than as a one-in-N crash on somebody's phone. + */ + @Test + fun `emulator mutation runs on the render thread, not the confined writer`() { + val renderThread = Executors.newSingleThreadExecutor { r -> Thread(r, RENDER_THREAD) } + val writerThread = Executors.newSingleThreadExecutor { r -> Thread(r, WRITER_THREAD) } + val threadInsideAppend = AtomicReference(null) + val session = RemoteTerminalSession( + engineSend = {}, + onTitleChanged = { threadInsideAppend.set(hostThreadName()) }, + appendDispatcher = writerThread.asCoroutineDispatcher(), + mainDispatcher = renderThread.asCoroutineDispatcher(), + ) + session.bind {} + + try { + session.feedRemote(TITLE_SEQUENCE) + awaitTrue("the OSC title to be parsed") { threadInsideAppend.get() != null } + } finally { + session.close() + renderThread.shutdownNow() + writerThread.shutdownNow() + } + + assertEquals( + RENDER_THREAD, + threadInsideAppend.get(), + "TerminalEmulator.append must run on the same thread that draws it (stock TerminalRenderer " + + "reads TerminalBuffer with no synchronisation of any kind — `monitorenter` appears " + + "nowhere in the pinned emulator or renderer)", + ) + } + + // ── Helpers ────────────────────────────────────────────────────────────────────────────────────── + + /** + * `TerminalRenderer.render`'s read contract, reduced to the indexing that can throw: cache the + * column/row bounds from the emulator (offsets 6-18), resolve each screen row through + * `externalToInternalRow` + `allocateFullLineIfNecessary` (offsets 173-185), then index the row's + * style array per column (offset 346). + */ + private fun renderLikeRead(emulator: TerminalEmulator) { + val columns = emulator.mColumns + val rows = emulator.mRows + val screen = emulator.screen + for (row in 0 until rows) { + val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)) + for (column in 0 until columns) { + line.getStyle(column) + } + } + } + + private fun readerOnRenderThread( + renderThread: java.util.concurrent.ExecutorService, + emulator: TerminalEmulator, + stop: AtomicBoolean, + failure: AtomicReference, + readsCompleted: AtomicInteger, + ): Job = CoroutineScope(renderThread.asCoroutineDispatcher()).launch { + while (!stop.get()) { + try { + renderLikeRead(emulator) + readsCompleted.incrementAndGet() + } catch (t: Throwable) { + failure.compareAndSet(null, t) + return@launch + } + // Single-threaded dispatcher: yielding is what lets a queued mutation run at all. Without it + // this loop would monopolise the render thread and the test would pass vacuously. + yield() + } + } + + /** + * The executor thread's own name, with the coroutines debug-agent's ` @coroutine#N` suffix stripped — + * that suffix is appended per-continuation, so the raw name is not a stable identity. + */ + private fun hostThreadName(): String = Thread.currentThread().name.substringBefore(COROUTINE_SUFFIX) + + private fun awaitTrue(what: String, predicate: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(AWAIT_TIMEOUT_SECONDS) + while (System.nanoTime() < deadline) { + if (predicate()) return + Thread.sleep(POLL_INTERVAL_MS) + } + assertTrue(predicate(), "timed out after $AWAIT_TIMEOUT_SECONDS s waiting for: $what") + } + + private companion object { + const val RENDER_THREAD = "test-render-thread" + const val WRITER_THREAD = "test-writer-thread" + + /** What `kotlinx-coroutines-debug` appends to a thread name while a continuation runs on it. */ + const val COROUTINE_SUFFIX = " @coroutine#" + + /** The observed crash was `length=31; index=31`; alternating 31 ↔ 64 reproduces it verbatim. */ + const val NARROW_COLS = 31 + const val WIDE_COLS = 64 + const val ROWS = 24 + + /** Deep enough that a reflow takes real time (the race window), shallow enough to stay fast. */ + const val TEST_TRANSCRIPT_ROWS = 4_000 + + /** The confined-thread version threw within a couple of iterations; 40 leaves no room for luck. */ + const val REGRID_ITERATIONS = 40 + + const val AWAIT_TIMEOUT_SECONDS = 30L + const val POLL_INTERVAL_MS = 5L + + /** Plain glyphs plus a newline, so the reflow has content to wrap rather than an empty grid. */ + val SAMPLE_OUTPUT: ByteArray = ("web terminal regrid probe ".repeat(8) + "\r\n").toByteArray() + + /** `OSC 0 ; title BEL` — parsed inside `append`, which is what makes the title a thread probe. */ + val TITLE_SEQUENCE: ByteArray = "\u001b]0;probe\u0007".toByteArray() + } +} From 8075d2c6713ac4e1b7134d1a3e17582c8360c622 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 13:42:26 +0200 Subject: [PATCH 08/10] test(android): the instrumented suite A34/A35 never had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan marked A34 (instrumented E2E against a real host) and A35 (macrobenchmark) DONE. Neither had a single line of code — :app had no androidTest source set at all, and `testInstrumentationRunner` named a HiltTestRunner class that did not exist. They had been converted into checklist bullets. This adds 67 instrumented tests and 3 macrobenchmarks, and every one of them has actually been executed on an emulator against this repo's own server — not merely compiled. Why this layer had to exist: four crash-on-first-interaction defects shipped behind a green 900-test suite, for one structural reason — no JVM test instantiates an android.view.View, creates an InputConnection, dispatches a MotionEvent or runs a layout pass. So the rig builds the REAL RemoteTerminalView in a REAL Activity and drives it through the REAL framework entry points, faking only the wire so assertions can be about BYTES rather than "did not crash". That distinction matters: a no-crash-only test would have passed against the silent-black-hole variant of the IME bug, which was arguably the worse half of it. The four crash regressions (28 tests): keys — Enter is \r not \n, Ctrl+C is 0x03, Backspace 0x7f, and arrows in BOTH DECCKM modes driven off the live emulator bit, so ESC[A vs ESC OA is asserted rather than assumed; BACK falls through and emits nothing; soft keyboard commitText/sendKeyEvent/deleteSurroundingText and a CJK composition pass through verbatim. alternate-screen scroll — every test feeds ESC[?1049h FIRST and asserts the buffer is active before touching anything, because that is the branch that used to crash. Real dispatchTouchEvent drags both directions, the DECCKM form, the mouse-tracking branch emitting a wheel report and NO arrows (branch order), the wheel's 3-rows-per-notch, and the fling proven not to start by 800 ms of silence after ACTION_UP. autofill — the framework's own autofill-structure gate is closed on both the frame and the stock child, before and after focus. resize — including the draw-race counterpart of the AIOOBE fixed in 538c8eb. A34 E2E against a live server (15 tests): attach → attached → output round trip, ring-buffer replay on reconnect, spawn failure via a second server deliberately configured with a bad SHELL_PATH, a held gate resolved through POST /hook/decision, and CswshDefenceE2eTest — the F9 bad-Origin rejection, which the checklist calls the one non-skippable defence and which had never been executed anywhere. A35 (3 macrobenchmarks): startup, and pair → attach → type → approve. Reaching a host from the emulator needs ALLOWED_ORIGINS=http://10.0.2.2:, because the server derives allowed origins from host NIC IPs and 10.0.2.2 is the emulator's alias for the host, not an interface address. Tests take the host as an instrumentation argument and SKIP with a clear message when it is absent, so "passed" can never be confused with "never ran". Verified independently of the authoring agent, on emulator-5554 (webterm AVD, android-35, arm64, software GPU), with both servers live: Starting 67 tests on webterm(AVD) - 15 Finished 67 tests on webterm(AVD) - 15 tests="67" failures="0" skipped="0" JVM gate unaffected: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 903 tests, 0 failures. --- .../wang/yaojia/webterm/HiltTestRunner.kt | 28 ++ .../yaojia/webterm/e2e/CswshDefenceE2eTest.kt | 106 ++++++ .../java/wang/yaojia/webterm/e2e/E2eSocket.kt | 185 +++++++++ .../java/wang/yaojia/webterm/e2e/HeldGate.kt | 115 ++++++ .../yaojia/webterm/e2e/HeldGateE2eTest.kt | 159 ++++++++ .../webterm/e2e/SessionRoundTripE2eTest.kt | 256 +++++++++++++ .../yaojia/webterm/e2e/SpawnFailureE2eTest.kt | 139 +++++++ .../yaojia/webterm/e2e/WebTermTestHost.kt | 219 +++++++++++ .../AlternateScreenScrollRegressionTest.kt | 188 ++++++++++ .../TerminalAutofillRegressionTest.kt | 109 ++++++ .../TerminalKeyInputRegressionTest.kt | 187 +++++++++ .../TerminalResizeDrawRaceRegressionTest.kt | 155 ++++++++ .../terminal/TerminalResizeRegressionTest.kt | 225 +++++++++++ .../webterm/terminal/TerminalTestHarness.kt | 354 ++++++++++++++++++ .../yaojia/webterm/ui/GateSurfacesUiTest.kt | 159 ++++++++ .../wang/yaojia/webterm/ui/KeyBarUiTest.kt | 150 ++++++++ .../webterm/ui/ReconnectBannerUiTest.kt | 134 +++++++ .../yaojia/webterm/ui/SessionListUiTest.kt | 134 +++++++ .../PairAttachTypeApproveBenchmark.kt | 156 ++++++++ .../macrobenchmark/StartupBenchmark.kt | 67 ++++ .../webterm/macrobenchmark/WebTermTarget.kt | 88 +++++ 21 files changed, 3313 insertions(+) create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/e2e/CswshDefenceE2eTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/e2e/E2eSocket.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGate.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGateE2eTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SessionRoundTripE2eTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SpawnFailureE2eTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/e2e/WebTermTestHost.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/terminal/AlternateScreenScrollRegressionTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalAutofillRegressionTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalKeyInputRegressionTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeDrawRaceRegressionTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeRegressionTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalTestHarness.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/ui/GateSurfacesUiTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/ui/KeyBarUiTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/ui/ReconnectBannerUiTest.kt create mode 100644 android/app/src/androidTest/java/wang/yaojia/webterm/ui/SessionListUiTest.kt create mode 100644 android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/PairAttachTypeApproveBenchmark.kt create mode 100644 android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/StartupBenchmark.kt create mode 100644 android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/WebTermTarget.kt diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt new file mode 100644 index 0000000..9e3f1d4 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt @@ -0,0 +1,28 @@ +package wang.yaojia.webterm + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +/** + * The instrumentation runner named by `:app`'s `testInstrumentationRunner`. + * + * A custom runner is **required**, not stylistic: [newApplication] is the only hook that can replace the + * app-under-test's [Application] with the generated [HiltTestApplication]. Setting `android:name` in the + * androidTest manifest cannot do it — that manifest is merged into the *test* APK, not into the app under + * test, so the real [WebTermApp] would still be installed and `@HiltAndroidTest` injection would fail. + * + * `assembleDebugAndroidTest` builds fine without this class (the runner name is only a manifest value); + * an actual instrumentation *run* would fail with `ClassNotFoundException`. See `android/README.md` → + * "Instrumented tests". + * + * Note that installing [HiltTestApplication] replaces [WebTermApp], so anything [WebTermApp] does in + * `onCreate` (push registration, notification channels) does NOT happen under instrumentation. That is + * deliberate — a test must opt into those via Hilt test modules rather than inherit them. + */ +public class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application = + super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/CswshDefenceE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/CswshDefenceE2eTest.kt new file mode 100644 index 0000000..537e4a9 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/CswshDefenceE2eTest.kt @@ -0,0 +1,106 @@ +package wang.yaojia.webterm.e2e + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.ServerMessage + +/** + * **F9 — bad `Origin` on the WS upgrade is rejected. The one non-skippable defence.** + * + * This app hands a full shell to anyone who can reach the port, and Origin validation on the WebSocket + * handshake is the single thing standing between that shell and a malicious page open in the user's + * browser (Cross-Site WebSocket Hijacking). TECH_DOC §7 and plan §8 both call it non-negotiable, and it is + * the only security control in this codebase that cannot be compensated for elsewhere. + * + * A unit test cannot verify it: the check lives in the *server's* upgrade handler, so the only honest proof + * is a real socket carrying a real foreign `Origin` and being refused. That is what this class does, from + * the device, against the real server. It also pins the two neighbouring cases the check must not get + * wrong — a MISSING Origin (default-deny) and the correct Origin (must still work, or the "defence" is + * just a broken client). + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class CswshDefenceE2eTest { + + /** + * The attack: a page on another origin opens `ws://:3000/term`. The browser stamps ITS origin, + * which is not in the server's allow-list, and the handshake must be refused with 401 — before any + * frame can be exchanged. + */ + @Test + fun aForeignOriginIsRefusedOnTheWsUpgrade() { + val host = WebTermTestHost.require() + val socket = E2eSocket.dial(host, origin = FOREIGN_ORIGIN) + try { + val failure = socket.failureOrNull() + + assertNotNull( + "the handshake must FAIL with a foreign Origin — it opened instead, which is a CSWSH hole", + failure, + ) + assertEquals( + "the refusal must be a 401 (src/server.ts writes it before destroying the socket)", + HTTP_UNAUTHORIZED, + socket.handshakeStatusOrNull(), + ) + } finally { + socket.cancel() + } + } + + /** + * Default-deny: a non-browser client that sends no `Origin` at all must also be refused. `curl` and + * scripts land here, and `isOriginAllowed(undefined, …)` returning false is the single central policy + * point — an allow-if-absent would make the whole check trivially bypassable. + */ + @Test + fun aMissingOriginIsRefusedOnTheWsUpgrade() { + val host = WebTermTestHost.require() + val socket = E2eSocket.dial(host, origin = null) + try { + assertNotNull( + "an absent Origin must be refused (default-deny), not treated as trusted", + socket.failureOrNull(), + ) + assertEquals(HTTP_UNAUTHORIZED, socket.handshakeStatusOrNull()) + } finally { + socket.cancel() + } + } + + /** + * The control: the SAME dial with the endpoint-derived `Origin` (byte-equal to what + * `HostEndpoint.originHeader` produces, and to what `src/http/origin.ts` expects) must open and attach. + * Without this, the two refusals above would also be satisfied by a server that rejects everything. + */ + @Test + fun theEndpointDerivedOriginIsAccepted() { + val host = WebTermTestHost.require() + val socket = E2eSocket.dial(host) + var sessionId: String? = null + try { + assertTrue( + "the production Origin must be accepted, got failure=${socket.failureOrNull()}", + socket.failureOrNull() == null, + ) + socket.send(ClientMessage.Attach(sessionId = null)) + sessionId = (socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached) + .sessionId + } finally { + socket.close() + sessionId?.let { runCatching { host.delete("/live-sessions/$it") } } + } + } + + private companion object { + /** Deliberately not a real host: the point is that it is not in the server's allow-list. */ + const val FOREIGN_ORIGIN = "http://evil.example" + const val HTTP_UNAUTHORIZED = 401 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/E2eSocket.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/E2eSocket.kt new file mode 100644 index 0000000..b174b90 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/E2eSocket.kt @@ -0,0 +1,185 @@ +package wang.yaojia.webterm.e2e + +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.MessageCodec +import wang.yaojia.webterm.wire.ServerMessage +import wang.yaojia.webterm.wire.WireConstants +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * One live WS connection to a real WebTerm server, driven synchronously so an E2E test reads as a + * transcript. + * + * It deliberately does NOT reuse `:transport-okhttp`'s [wang.yaojia.webterm.transport.OkHttpTermTransport]: + * that class stamps `Origin` from the endpoint by construction, which is exactly the thing the F9 test has + * to be able to falsify. So the upgrade request is built here, and `Origin` is a parameter. Everything + * else that matters IS production code — [MessageCodec] encodes and decodes every frame, so these tests + * are a check of the frozen wire contract against the real server, not of a parallel test codec. + */ +internal class E2eSocket private constructor( + private val opened: CountDownLatch, + private val closed: CountDownLatch, +) : WebSocketListener() { + + private val frames = LinkedBlockingQueue() + + /** + * Every `output` payload, accumulated as frames ARRIVE rather than as a test pulls them. + * + * This is not an optimisation, it is a correctness requirement. On a re-attach the server replays the + * ring buffer INSIDE `manager.handleAttach`, i.e. it puts the replay `output` frame on the wire BEFORE + * the `attached` confirmation. A reader that scanned the queue for `attached` first would consume and + * discard the replay on the way past — which is exactly how the first run of the replay test reported + * "0 chars" against a server that had sent hundreds of kilobytes. + */ + private val output = StringBuilder() + private val lastFrameAt = java.util.concurrent.atomic.AtomicLong(System.nanoTime()) + private val socket = AtomicReference(null) + private val failure = AtomicReference(null) + private val handshakeStatus = AtomicReference(null) + private val closeCode = AtomicReference(null) + + override fun onOpen(webSocket: WebSocket, response: Response) { + handshakeStatus.set(response.code) + socket.set(webSocket) + opened.countDown() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + lastFrameAt.set(System.nanoTime()) + frames.put(text) + (MessageCodec.decodeServer(text) as? ServerMessage.Output)?.let { frame -> + synchronized(output) { output.append(frame.data) } + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + handshakeStatus.set(response?.code) + failure.set(t) + opened.countDown() + closed.countDown() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + closeCode.set(code) + closed.countDown() + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + closeCode.set(code) + webSocket.close(code, reason) + } + + // ── driving ────────────────────────────────────────────────────────────────────────────────── + + fun send(message: ClientMessage) { + val live = socket.get() + assertNotNull("cannot send $message — the WS never opened", live) + assertTrue("the WS refused to enqueue $message", live!!.send(MessageCodec.encode(message))) + } + + /** The next decodable server frame matching [predicate], or fail with what actually arrived. */ + fun awaitFrame(what: String, timeoutMs: Long = FRAME_TIMEOUT_MS, predicate: (ServerMessage) -> Boolean): ServerMessage { + val seen = ArrayList() + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs) + while (System.nanoTime() < deadline) { + val remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()).coerceAtLeast(1) + val raw = frames.poll(remaining, TimeUnit.MILLISECONDS) ?: break + seen += raw.take(RAW_FRAME_LOG_CHARS) + val decoded = MessageCodec.decodeServer(raw) + if (decoded != null && predicate(decoded)) return decoded + } + throw AssertionError( + "no frame matching \"$what\" within $timeoutMs ms. Frames seen: $seen. " + + "failure=${failure.get()} closeCode=${closeCode.get()}", + ) + } + + /** + * Wait for the wire to go quiet for [quietMs], then take everything `output` has carried since the last + * call. Bounded by [DRAIN_CEILING_MS] so a session that never stops talking cannot hang the suite. + */ + fun drainOutput(quietMs: Long = QUIET_MS): String { + val ceiling = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DRAIN_CEILING_MS) + while (System.nanoTime() < ceiling) { + val idleMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastFrameAt.get()) + if (idleMs >= quietMs) break + Thread.sleep(DRAIN_POLL_MS) + } + return synchronized(output) { + val text = output.toString() + output.setLength(0) + text + } + } + + fun awaitClosed(timeoutMs: Long = FRAME_TIMEOUT_MS): Boolean = + closed.await(timeoutMs, TimeUnit.MILLISECONDS) + + fun failureOrNull(): Throwable? = failure.get() + + fun handshakeStatusOrNull(): Int? = handshakeStatus.get() + + fun close() { + socket.get()?.close(NORMAL_CLOSURE, null) + } + + fun cancel() { + socket.get()?.cancel() + } + + companion object { + const val NORMAL_CLOSURE: Int = 1000 + private const val OPEN_TIMEOUT_MS = 8_000L + private const val FRAME_TIMEOUT_MS = 10_000L + private const val QUIET_MS = 700L + private const val DRAIN_CEILING_MS = 30_000L + private const val DRAIN_POLL_MS = 50L + private const val RAW_FRAME_LOG_CHARS = 200 + + /** + * Dial the host's `/term`. Returns as soon as the handshake resolves either way, so a rejection + * (the F9 case) is observable rather than a hang. + * + * @param origin what to put in the `Origin` header. Defaults to the endpoint-derived value, i.e. + * exactly what production sends; a foreign value is how the CSWSH defence is falsified. + */ + fun dial( + host: WebTermTestHost.ReachableHost, + origin: String? = host.endpoint.originHeader, + ): E2eSocket { + val opened = CountDownLatch(1) + val listener = E2eSocket(opened, CountDownLatch(1)) + val request = Request.Builder() + .url(host.endpoint.wsUrl) + .apply { origin?.let { header("Origin", it) } } + .build() + host.client.newWebSocket(request, listener) + assertTrue( + "the WS handshake to ${host.endpoint.wsUrl} neither opened nor failed within $OPEN_TIMEOUT_MS ms", + opened.await(OPEN_TIMEOUT_MS, TimeUnit.MILLISECONDS), + ) + return listener + } + + /** `attach` must be the FIRST frame on every (re)connect (plan §4.1). */ + fun attach(host: WebTermTestHost.ReachableHost, sessionId: String?, cwd: String? = null): Pair { + val socket = dial(host) + socket.send(ClientMessage.Attach(sessionId = sessionId, cwd = cwd)) + val attached = socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached + return socket to attached.sessionId + } + + /** The soft-reset prefix the server puts in front of a ring-buffer replay — never stripped (§4.1). */ + const val REPLAY_PREFIX: String = WireConstants.REPLAY_SOFT_RESET_PREFIX + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGate.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGate.kt new file mode 100644 index 0000000..57659d1 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGate.kt @@ -0,0 +1,115 @@ +package wang.yaojia.webterm.e2e + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.junit.Assert.assertTrue +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * A permission gate held open on a real server, created exactly the way Claude Code creates one. + * + * `POST /hook/permission` is the hook side-channel: the server holds the HTTP request open until a decision + * arrives (or it times out), then writes the decision JSON as the response — which the hook's `curl` feeds + * back to Claude on stdout. So the *held request itself* is the observable for "is the gate still held", and + * that is the only way to tell a real resolution from a UI that merely stopped showing the banner. + * + * Two server preconditions are worth knowing before reading a failure here: + * - **Loopback only.** `POST /hook/permission` 403s a non-loopback peer. From an emulator this is satisfied + * for free: `10.0.2.2` is forwarded to the host's loopback, so the server sees `127.0.0.1`. Over a LAN + * address it will 403, and [hold] says so rather than timing out mysteriously. + * - **Someone must be watching.** The server only HOLDS when the session has an attached client or a + * registered push target; otherwise it answers `{}` immediately and lets Claude prompt locally. The + * caller therefore attaches first. + */ +internal class HeldGate private constructor( + private val call: Call, + private val completed: CountDownLatch, + private val body: AtomicReference, + private val failure: AtomicReference, +) { + /** Whether the server has already answered — i.e. the gate is no longer held. */ + fun hasResponded(): Boolean = completed.count == 0L + + /** The decision body the server finally wrote, or fail if the hold never resolved. */ + fun awaitResponse(timeoutMs: Long = RESPONSE_TIMEOUT_MS): String { + val done = completed.await(timeoutMs, TimeUnit.MILLISECONDS) + if (!done) { + call.cancel() + throw AssertionError("the held /hook/permission request never resolved within $timeoutMs ms") + } + failure.get()?.let { throw AssertionError("the held /hook/permission request failed: $it") } + return body.get() ?: "" + } + + companion object { + private const val RESPONSE_TIMEOUT_MS = 15_000L + private const val HOLD_SETTLE_MS = 600L + private val JSON = "application/json; charset=utf-8".toMediaType() + + /** The tool name the server maps to a plain two-way `tool` gate (anything but `ExitPlanMode`). */ + private const val TOOL_NAME = "Bash" + + /** + * Start holding a gate for [sessionId]. Returns once the request is in flight and the server has + * had time to either hold it or refuse it — and fails loudly if it refused, because a test that + * proceeded from here would be asserting against a gate that does not exist. + */ + fun hold(host: WebTermTestHost.ReachableHost, sessionId: String): HeldGate { + val completed = CountDownLatch(1) + val body = AtomicReference(null) + val failure = AtomicReference(null) + val request = Request.Builder() + .url(host.endpoint.baseUrl + "/hook/permission") + .header("x-webterm-session", sessionId) + // `tool_input` is untrusted by contract; a harmless command keeps the derived preview real. + .post( + """{"tool_name":"$TOOL_NAME","tool_input":{"command":"echo webterm-e2e-gate"}}""" + .toByteArray() + .toRequestBody(JSON), + ) + .build() + // Its own client: the hold occupies the connection for as long as the gate lives, and the read + // timeout has to outlast that, which must not be imposed on the rest of the suite. + val holdingClient = host.client.newBuilder() + .readTimeout(RESPONSE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS) + .build() + val call = holdingClient.newCall(request) + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + failure.set(e) + completed.countDown() + } + + override fun onResponse(call: Call, response: Response) { + response.use { body.set(it.body?.string() ?: "") } + completed.countDown() + } + }) + + // Give the server a moment to register the hold (or refuse it outright). + Thread.sleep(HOLD_SETTLE_MS) + val gate = HeldGate(call, completed, body, failure) + if (gate.hasResponded()) { + val answered = body.get().orEmpty().replace(" ", "") + assertTrue( + """ + |POST /hook/permission did not HOLD, so there is no gate to test. It answered immediately + |with "$answered" (failure=${failure.get()}). The two reasons this happens: + | 1. the peer was not loopback (403) — use the emulator's http://10.0.2.2: alias, + | which the host sees as 127.0.0.1, rather than a LAN address; + | 2. nothing was watching the session ({}) — attach a client before holding. + """.trimMargin(), + false, + ) + } + return gate + } + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGateE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGateE2eTest.kt new file mode 100644 index 0000000..a355858 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGateE2eTest.kt @@ -0,0 +1,159 @@ +package wang.yaojia.webterm.e2e + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ApproveMode +import wang.yaojia.webterm.wire.ClaudeStatus +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.GateKind +import wang.yaojia.webterm.wire.ServerMessage +import java.util.UUID + +/** + * **A34 — a held permission gate, and what may and may not resolve it.** + * + * A gate is created the way Claude Code creates one: `POST /hook/permission` from the host's own loopback + * (which is what the emulator's `10.0.2.2` alias resolves to on the host side, so the loopback-only guard + * is satisfied without any special setup). The server holds that HTTP request open until a decision + * arrives, and pushes a `status` frame with `pending: true` to every attached client — which is the frame + * the cockpit's gate card renders. + * + * ### An honest boundary: the `/hook/decision` HAPPY path is not automatable + * `/hook/decision` requires the per-decision capability token, and that token leaves the server **only** + * inside a push payload (`pushService.notify(session, 'needs-input', token)`) — never over the WS, never in + * `/live-sessions`. That is deliberate payload minimisation (plan §8), and it means no automated client can + * legitimately obtain it. So this class proves the parts that ARE provable and does not fake the rest: + * + * - a gate really is held, and the held-gate `status` frame really reaches an attached device; + * - `/hook/decision` **refuses** a well-formed but wrong token (403) and a malformed body (400), and the + * gate is still held afterwards — the security half, and the half a bug would most likely break; + * - the gate resolves through the in-app path (`approve` on the WS), observed by the held + * `POST /hook/permission` request finally returning. + * + * The remaining step — a real notification-action POST with a real token — stays on the device-QA checklist + * under Push, where it belongs, because it needs a real FCM delivery. It is NOT quietly asserted here. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class HeldGateE2eTest { + + /** + * A wrong capability token must be refused and must leave the gate held. This is the SEC-C1/M1 + * contract: the token is the whole authorisation for a decision made from a lock screen, so a + * mismatched, stale or absent one has to be a hard 403. + */ + @Test + fun hookDecisionRefusesAWrongTokenAndLeavesTheGateHeld() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + val gate = HeldGate.hold(host, sessionId) + try { + // The gate must actually be held before the refusal means anything. + val status = socket.awaitFrame("the held-gate status frame") { + it is ServerMessage.Status && it.pending + } as ServerMessage.Status + assertEquals("a plain tool gate must be reported as such", GateKind.TOOL, status.gate) + assertEquals(ClaudeStatus.WAITING, status.status) + + val wrongToken = UUID.randomUUID().toString() + val (refused, _) = host.postJson( + "/hook/decision", + """{"sessionId":"$sessionId","decision":"allow","token":"$wrongToken"}""", + origin = host.endpoint.originHeader, + ) + assertEquals( + "a mismatched capability token must 403 — it is the only thing authorising a remote decision", + WebTermTestHost.ReachableHost.FORBIDDEN, + refused, + ) + + val (malformed, _) = host.postJson( + "/hook/decision", + """{"sessionId":"$sessionId","decision":"maybe","token":"$wrongToken"}""", + origin = host.endpoint.originHeader, + ) + assertEquals( + "an unknown decision verb must 400, never be coerced into allow", + HTTP_BAD_REQUEST, + malformed, + ) + + assertTrue( + "the refusals must not have resolved the gate — the hook request must still be held", + !gate.hasResponded(), + ) + } finally { + socket.send(ClientMessage.Reject) // release the hold so the server is left clean + gate.awaitResponse() + socket.close() + runCatching { host.delete("/live-sessions/$sessionId") } + } + } + + /** + * `/hook/decision` is a GUARDED route, so a foreign `Origin` must 403 before the token is even looked + * at. Otherwise a malicious page could spend a token it somehow observed. + */ + @Test + fun hookDecisionRefusesAForeignOrigin() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + try { + val (status, _) = host.postJson( + "/hook/decision", + """{"sessionId":"$sessionId","decision":"allow","token":"${UUID.randomUUID()}"}""", + origin = FOREIGN_ORIGIN, + ) + + assertEquals( + "the Origin guard must run before the token check on /hook/decision", + WebTermTestHost.ReachableHost.FORBIDDEN, + status, + ) + } finally { + socket.close() + runCatching { host.delete("/live-sessions/$sessionId") } + } + } + + /** + * The in-app resolution path, end to end: the attached device sends `approve` (with the mode as a + * TOP-LEVEL key, per plan §4.1) and the held `POST /hook/permission` request returns — which is the + * server telling Claude Code to proceed. Observing that return is the only proof the hold was really + * released rather than merely hidden in the UI. + */ + @Test + fun approveOnTheWireResolvesTheHeldGate() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + val gate = HeldGate.hold(host, sessionId) + try { + socket.awaitFrame("the held-gate status frame") { it is ServerMessage.Status && it.pending } + assertTrue("the hook request must still be held before we decide", !gate.hasResponded()) + + socket.send(ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS)) + + val body = gate.awaitResponse() + assertTrue( + "the held hook request must return a decision body, got: $body", + body.isNotEmpty() && body.trimStart().startsWith("{"), + ) + assertTrue( + "an approval must not come back as a plain empty object (that is the timeout fallback)", + body.replace(" ", "") != "{}", + ) + } finally { + socket.close() + runCatching { host.delete("/live-sessions/$sessionId") } + } + } + + private companion object { + const val HTTP_BAD_REQUEST = 400 + const val FOREIGN_ORIGIN = "http://evil.example" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SessionRoundTripE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SessionRoundTripE2eTest.kt new file mode 100644 index 0000000..f872d6f --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SessionRoundTripE2eTest.kt @@ -0,0 +1,256 @@ +package wang.yaojia.webterm.e2e + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.ServerMessage +import wang.yaojia.webterm.wire.Validation +import wang.yaojia.webterm.wire.WireConstants +import java.util.UUID + +/** + * **A34 — instrumented E2E against a real `npm start` host.** + * + * Every test here runs on the device, over a real socket, against this repo's own server. The frames are + * encoded and decoded by production's [wang.yaojia.webterm.wire.MessageCodec], so a drift between the + * frozen client contract and the actual server surfaces here and nowhere else in the suite. + * + * If no host is reachable each test SKIPS with the command needed to make it reachable — see + * [WebTermTestHost]. It never quietly passes. + * + * Every session this class spawns is killed in a `finally`, via the guarded + * `DELETE /live-sessions/:id`, so a run leaves no orphan PTYs behind on the developer's machine. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class SessionRoundTripE2eTest { + + /** + * The core loop the whole product rests on: `attach(null)` → `attached` with a server-issued id → + * typed bytes → the shell's own output comes back. Also pins the two contract details a client gets + * wrong most easily: the id must be adopted from the server (never the one we asked for), and Enter is + * `\r`. + */ + @Test + fun attachThenTypeThenOutput_roundTripsThroughARealPty() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + try { + assertTrue( + "the server-issued session id must be a lowercase v4 UUID: $sessionId", + Validation.isValidSessionId(sessionId), + ) + assertEquals("…and must parse as a UUID", sessionId, UUID.fromString(sessionId).toString()) + + // A marker the shell will echo back through the PTY. `\r` (never `\n`) is what Enter sends. + val marker = "webterm-e2e-${UUID.randomUUID()}" + socket.send(ClientMessage.Input("printf '%s\\n' $marker\r")) + + val output = socket.awaitFrame("the marker echoed back by the shell") { + it is ServerMessage.Output && it.data.contains(marker) + } + assertTrue("expected an output frame, got $output", output is ServerMessage.Output) + } finally { + socket.close() + host.killQuietly(sessionId) + } + } + + /** + * `attach` MUST be the first frame, with the `sessionId` key ALWAYS present (JSON `null` for a new + * session — the server rejects a missing key). Sending something else first must not bind a session: + * the server logs a protocol violation and keeps waiting, so the later `attach` still works. This is + * the ordering guarantee the whole reconnect ladder depends on. + */ + @Test + fun aNonAttachFirstFrameBindsNothing_andTheLaterAttachStillWorks() { + val host = WebTermTestHost.require() + val socket = E2eSocket.dial(host) + var sessionId: String? = null + try { + socket.send(ClientMessage.Input("this must be ignored\r")) + socket.send(ClientMessage.Resize(cols = 100, rows = 40)) + + socket.send(ClientMessage.Attach(sessionId = null)) + val attached = socket.awaitFrame("attached after an out-of-order first frame") { + it is ServerMessage.Attached + } as ServerMessage.Attached + sessionId = attached.sessionId + assertTrue(Validation.isValidSessionId(attached.sessionId)) + } finally { + socket.close() + sessionId?.let { host.killQuietly(it) } + } + } + + /** + * **F5/F6 — reconnect replays the ring buffer, with no dropped bytes on a multi-MB replay.** + * + * The session is made to produce a large volume of output, the client detaches (which must NOT kill + * the PTY), then re-attaches by id. Every byte of the replay has to arrive: the assertion is not "some + * output came back" but that a *numbered sequence* is present, in order, with no gap — the only way to + * catch a truncated or interleaved replay. `MessageCodec` decodes the replay frame, so this also + * exercises the client's ability to receive a single very large text frame. + */ + @Test + fun reconnectReplaysTheRingBufferWithNoDroppedBytes() { + val host = WebTermTestHost.require() + val (first, sessionId) = E2eSocket.attach(host, sessionId = null) + try { + // seq prints REPLAY_LINES numbered lines; awk pads each to ~PAD_BYTES so the ring holds + // hundreds of KB to a few MB of real PTY output rather than a handful of lines. + first.send( + ClientMessage.Input( + "seq 1 $REPLAY_LINES | awk '{ printf \"%s:\", \$1; " + + "for (i = 0; i < $PAD_REPEATS; i++) printf \"$PAD_CHUNK\"; printf \"\\n\" }'\r", + ), + ) + first.awaitFrame("the last line of the generated output") { + it is ServerMessage.Output && it.data.contains("$REPLAY_LINES:") + } + first.drainOutput() + // Detach only. The PTY must survive — that is the product's central design point. + first.close() + assertTrue("the first connection never closed", first.awaitClosed()) + + val stillRunning = host.get("/live-sessions").second + assertTrue( + "the session must survive the detach (PTY lifecycle != WS lifecycle)", + stillRunning.contains(sessionId), + ) + + val (second, adopted) = E2eSocket.attach(host, sessionId = sessionId) + try { + assertEquals("re-attach must adopt the same id", sessionId, adopted) + val replay = second.drainOutput(quietMs = REPLAY_QUIET_MS) + + assertTrue( + "a replay must arrive at all; got ${replay.length} chars", + replay.length > MIN_REPLAY_CHARS, + ) + assertTrue( + "the ESC[0m soft-reset prefix must be passed through, not stripped", + replay.contains(WireConstants.REPLAY_SOFT_RESET_PREFIX), + ) + // The ring is a fixed-size window, so the OLDEST lines are legitimately gone. What must + // never happen is a HOLE: from the first line present to the last, every number must be + // there. That is what a dropped or reordered chunk would break. + // Anchored at a line start: an unanchored `contains("234:")` would also match inside + // "1234:", which would silently inflate the set and make the contiguity check vacuous. + val present = (1..REPLAY_LINES).filter { replay.contains("\n$it:") } + assertTrue("no numbered line survived the replay at all", present.isNotEmpty()) + assertEquals( + "the replay has a hole — lines ${present.first()}..${present.last()} should be contiguous", + (present.first()..present.last()).toList(), + present, + ) + assertTrue( + "the newest line must always be in the ring", + replay.contains("\n$REPLAY_LINES:"), + ) + } finally { + second.close() + } + } finally { + host.killQuietly(sessionId) + } + } + + /** + * A shell that exits ends the session: `exit` is terminal, and the row disappears from + * `/live-sessions`. This is the ordinary exit path — the `-1` spawn-failure path needs a deliberately + * broken server and lives in [SpawnFailureE2eTest]. + */ + @Test + fun aShellThatExitsProducesATerminalExitFrame() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + try { + socket.send(ClientMessage.Input("exit $EXPECTED_EXIT_CODE\r")) + + val exit = socket.awaitFrame("the exit frame") { it is ServerMessage.Exit } as ServerMessage.Exit + assertEquals("the shell's own exit status must be reported verbatim", EXPECTED_EXIT_CODE, exit.code) + } finally { + socket.close() + host.killQuietly(sessionId) + } + } + + /** + * **Kill via the guarded `DELETE /live-sessions/:id`** (the swipe-to-kill action). 204 the first time, + * and **404 the second time counts as success** — "already gone" is the desired end state, which is + * why the client must not treat it as an error (plan §4.3). + */ + @Test + fun killingASessionRemovesItAndASecondKillIsAlreadyGone() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + socket.close() + + val (firstStatus, _) = host.delete("/live-sessions/$sessionId") + assertEquals("the first kill must be a 204", WebTermTestHost.ReachableHost.NO_CONTENT, firstStatus) + + assertTrue( + "the killed session must be gone from /live-sessions", + !host.get("/live-sessions").second.contains(sessionId), + ) + + val (secondStatus, _) = host.delete("/live-sessions/$sessionId") + assertEquals( + "a second kill must be 404 (already gone = success), not an error the UI would surface", + WebTermTestHost.ReachableHost.NOT_FOUND, + secondStatus, + ) + } + + /** + * The Origin 铁律, positive half: the guarded kill route must be REFUSED without a byte-equal `Origin`. + * The negative half (a foreign origin on the WS upgrade, F9) is [CswshDefenceE2eTest]. + */ + @Test + fun theGuardedKillRouteRefusesAForeignOrigin() { + val host = WebTermTestHost.require() + val (socket, sessionId) = E2eSocket.attach(host, sessionId = null) + socket.close() + try { + val (status, _) = host.delete("/live-sessions/$sessionId", origin = FOREIGN_ORIGIN) + + assertEquals( + "a foreign Origin must 403 on a state-changing route", + WebTermTestHost.ReachableHost.FORBIDDEN, + status, + ) + assertTrue( + "…and the session must still be alive, i.e. the refusal actually refused", + host.get("/live-sessions").second.contains(sessionId), + ) + } finally { + host.killQuietly(sessionId) + } + } + + /** Best-effort cleanup: 204 and 404 are both fine, and a transport error must not mask a real failure. */ + private fun WebTermTestHost.ReachableHost.killQuietly(sessionId: String) { + runCatching { delete("/live-sessions/$sessionId") } + } + + private companion object { + /** ~REPLAY_LINES × (PAD_REPEATS × 64) bytes of PTY output — comfortably past a single WS frame. */ + const val REPLAY_LINES = 4_000 + const val PAD_REPEATS = 8 + const val PAD_CHUNK = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + const val MIN_REPLAY_CHARS = 100_000 + const val REPLAY_QUIET_MS = 2_500L + + /** An arbitrary non-zero status, chosen so a coincidental 0 cannot pass the assertion. */ + const val EXPECTED_EXIT_CODE = 42 + + /** Not a real host — the point is that it is not in the server's allow-list. */ + const val FOREIGN_ORIGIN = "http://evil.example" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SpawnFailureE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SpawnFailureE2eTest.kt new file mode 100644 index 0000000..cdab7ed --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SpawnFailureE2eTest.kt @@ -0,0 +1,139 @@ +package wang.yaojia.webterm.e2e + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.components.BannerModel +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.ServerMessage +import wang.yaojia.webterm.wire.WireConstants + +/** + * **A34 — a host whose shell cannot start, and the spawn-failure banner.** + * + * ## What was expected, and what a real server actually does — MEASURED, 2026-07-30 + * Plan §4.1 and `src/server.ts` (M4) say a spawn failure surfaces as `exit(-1)` with a required `reason`: + * `manager.handleAttach` throws, the server sends `exit(-1)` and closes that connection. The client turns + * that into the non-retryable "会话启动失败" banner instead of entering the reconnect ladder. + * + * **That path did not fire.** A server started with `SHELL_PATH=/nonexistent/shell` (port 3112, run against + * this suite on 2026-07-30) answered `attached` and then `exit` with **code 1, not -1**. The reason is + * structural, not a configuration slip: node-pty's `pty.fork()` returns successfully and the `execvp` + * failure happens in the FORKED CHILD, which `_exit(1)`s (`node_modules/node-pty/src/unix/pty.cc`, and the + * same in `spawn-helper.cc` for `chdir`). So nothing throws synchronously in `createSession`, `handleAttach` + * returns a live session, and the failure arrives later as an ordinary child exit. On POSIX, `exit(-1)` + * therefore looks **unreachable through any client-visible route** — which is reported to the plan owner + * rather than papered over here. + * + * ## So this class asserts what is true, and never pretends + * - Over the wire, against the deliberately-broken host: a session whose shell cannot be executed produces + * a **terminal, non-zero `exit`** and does not linger in `/live-sessions`. That is the behaviour a client + * must actually handle, and asserting `-1` here would be asserting a fiction. + * - The `-1` → banner reduction is asserted separately and is labelled as a MODEL assertion, not an + * over-the-wire one, because no reachable server input produces `-1` today. If the server ever gains a + * real `-1` path, the wire test above starts distinguishing the two and this stays correct. + * + * Without `-e webtermSpawnFailureHost` the wire test SKIPS with the exact command to provide one — it is + * never a silent pass. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class SpawnFailureE2eTest { + + /** + * A host whose `SHELL_PATH` does not exist must not leave the user with a half-alive session: the + * `exit` frame has to arrive, be non-zero, and the session must be gone afterwards. Anything less and a + * walked-away user would sit on a "connecting…" spinner against a shell that can never run. + */ + @Test + fun aHostWhoseShellCannotStartProducesATerminalNonZeroExit() { + val host = WebTermTestHost.requireSpawnFailureHost() + val socket = E2eSocket.dial(host) + var sessionId: String? = null + try { + socket.send(ClientMessage.Attach(sessionId = null)) + + val frame = socket.awaitFrame("either attached or a terminal exit") { + it is ServerMessage.Attached || it is ServerMessage.Exit + } + (frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId } + + val exit = frame as? ServerMessage.Exit + ?: socket.awaitFrame("the exit that follows a failed exec") { it is ServerMessage.Exit } + as ServerMessage.Exit + + assertTrue( + "a shell that cannot be executed must produce a NON-ZERO exit, got ${exit.code}", + exit.code != CLEAN_EXIT, + ) + sessionId?.let { id -> + assertTrue( + "the dead session must not linger in /live-sessions", + !host.get("/live-sessions").second.contains(id), + ) + } + } finally { + socket.close() + sessionId?.let { runCatching { host.delete("/live-sessions/$it") } } + } + } + + /** + * The CLIENT half of the `exit(-1)` contract, asserted on the model rather than over the wire — see the + * class doc for why no reachable server input produces `-1`. It stays here, next to the wire test, so + * the two are read together and the gap is visible instead of forgotten: if the sentinel ever does + * arrive, this is the behaviour it must drive. + */ + @Test + fun theMinusOneSentinelReducesToTheNonRetryableSpawnFailureBanner() { + val banner = BannerModel.Exited(WireConstants.SPAWN_FAILED_EXIT_CODE, "spawn /nonexistent/shell ENOENT") + + assertTrue("code -1 must be recognised as a spawn failure", banner.isSpawnFailure) + assertTrue("a spawn failure must never show a retry spinner", !banner.showsSpinner) + assertTrue("…and must offer a new session instead", banner.offersNewSession) + assertNotNull("`reason` is what the banner shows the user; it is required on -1", banner.reason) + } + + /** + * The neighbouring case a client must NOT confuse with a spawn failure: a bad `cwd` kills the child + * (node-pty's `spawn-helper` `chdir` → `_exit(1)`), which is an ordinary exit. Reporting it as `-1` + * would stop the client offering a retry in a case where retrying elsewhere works. + */ + @Test + fun aBadCwdIsAnOrdinaryExitNotASpawnFailure() { + val host = WebTermTestHost.require() + val socket = E2eSocket.dial(host) + var sessionId: String? = null + try { + socket.send( + ClientMessage.Attach(sessionId = null, cwd = "/webterm-e2e/definitely-does-not-exist"), + ) + + val frame = socket.awaitFrame("either attached or exit") { + it is ServerMessage.Attached || it is ServerMessage.Exit + } + (frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId } + + val exit = frame as? ServerMessage.Exit + ?: socket.awaitFrame("the child exiting after chdir failed") { it is ServerMessage.Exit } + as ServerMessage.Exit + + assertEquals( + "a bad cwd must not be reported as the spawn-failure sentinel — it is the child that died", + false, + exit.code == WireConstants.SPAWN_FAILED_EXIT_CODE, + ) + } finally { + socket.close() + sessionId?.let { runCatching { host.delete("/live-sessions/$it") } } + } + } + + private companion object { + const val CLEAN_EXIT = 0 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/WebTermTestHost.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/WebTermTestHost.kt new file mode 100644 index 0000000..2dc976d --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/WebTermTestHost.kt @@ -0,0 +1,219 @@ +package wang.yaojia.webterm.e2e + +import androidx.test.platform.app.InstrumentationRegistry +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.junit.Assume +import wang.yaojia.webterm.wire.HostEndpoint +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Locating (or honestly declining to locate) a real WebTerm server for the A34 instrumented E2E suite. + * + * ### The rule this file exists to enforce + * **A test that cannot tell "passed" from "never ran" is worse than no test.** Every E2E test therefore + * goes through [require], which either hands back a *proven-reachable* host or raises a JUnit assumption + * failure carrying the exact command needed to make it reachable. It never returns a host it has not + * spoken to, and it never lets a test body no-op its way to green. + * + * ### Where the address comes from (never hardcoded, never personal) + * 1. `-e webtermHost ` — an instrumentation argument. This is how a LAN address is supplied; it + * is read at run time so no host of anyone's ever enters the source tree. + * 2. Otherwise [EMULATOR_HOST_ALIAS], which is not a personal address at all: `10.0.2.2` is the Android + * emulator's fixed platform alias for the host machine's loopback, documented by Google, and it is the + * right default because the server under test is this repo's own `npm start`. + * + * ### Two things that will bite whoever runs this + * - **`Origin`.** The server derives its allow-list from the host's NIC addresses (`src/config.ts` + * `deriveAllowedOrigins`), and `10.0.2.2` is a *guest-side* alias that never appears among them. So the + * three guarded routes 403 unless the server is started with that origin allowed: + * `ALLOWED_ORIGINS=http://10.0.2.2:3000 npm start`. [require] proves this up front rather than letting + * individual tests fail obscurely. + * - **`WEBTERM_TOKEN`.** If the server is token-gated, every route (and the WS upgrade) needs the + * `webterm_auth` cookie. Pass the token with `-e webtermToken ` and it is exchanged via `POST /auth` + * into the in-memory jar installed on the one [OkHttpClient] both the REST and WS halves use — + * the same "one client, one jar" shape production relies on. The token is never logged and never written + * anywhere. + */ +internal object WebTermTestHost { + + const val ARG_HOST: String = "webtermHost" + const val ARG_TOKEN: String = "webtermToken" + const val ARG_SPAWN_FAILURE_HOST: String = "webtermSpawnFailureHost" + + /** The emulator's fixed alias for the host machine's loopback (an Android platform constant). */ + const val EMULATOR_HOST_ALIAS: String = "http://10.0.2.2:3000" + + private const val CONNECT_TIMEOUT_S = 3L + private const val READ_TIMEOUT_S = 10L + private const val HTTP_OK = 200 + private const val HTTP_UNAUTHORIZED = 401 + private const val HTTP_NO_CONTENT = 204 + private const val HTTP_FORBIDDEN = 403 + private const val HTTP_NOT_FOUND = 404 + + private val JSON = "application/json; charset=utf-8".toMediaType() + + fun argument(name: String): String? = + InstrumentationRegistry.getArguments().getString(name)?.takeIf { it.isNotBlank() } + + /** + * A reachable host, or a JUnit assumption failure (reported as SKIPPED, never as a pass) explaining + * exactly how to provide one. + */ + fun require(): ReachableHost { + val baseUrl = argument(ARG_HOST) ?: EMULATOR_HOST_ALIAS + val endpoint = HostEndpoint.fromBaseUrl(baseUrl) + Assume.assumeTrue( + "`-e $ARG_HOST $baseUrl` is not a usable http(s) base URL, so no E2E host could be built.", + endpoint != null, + ) + return probe(endpoint!!, argument(ARG_TOKEN)) + } + + /** + * The optional second host for the "shell cannot start" case. It needs its own deliberately-broken + * instance because no client input can make a healthy host fail to spawn. + * + * MEASURED 2026-07-30: such a host answers `exit` with code **1**, not the documented `-1` sentinel — + * node-pty's `pty.fork()` succeeds and the `execvp` failure happens in the forked child, which + * `_exit(1)`s. See [SpawnFailureE2eTest] for the full finding. + */ + fun requireSpawnFailureHost(): ReachableHost { + val baseUrl = argument(ARG_SPAWN_FAILURE_HOST) + Assume.assumeTrue( + """ + |SKIPPED — no broken-shell host was supplied, so the failed-spawn path could not be exercised. + |Start a second server whose shell does not exist and point the suite at it: + | SHELL_PATH=/nonexistent/shell PORT=3112 ALLOWED_ORIGINS=http://10.0.2.2:3112 npm start + | ./gradlew :app:connectedDebugAndroidTest \ + | -Pandroid.testInstrumentationRunnerArguments.$ARG_SPAWN_FAILURE_HOST=http://10.0.2.2:3112 + """.trimMargin(), + baseUrl != null, + ) + val endpoint = HostEndpoint.fromBaseUrl(baseUrl!!) + Assume.assumeTrue("`-e $ARG_SPAWN_FAILURE_HOST $baseUrl` is not a usable http(s) base URL.", endpoint != null) + return probe(endpoint!!, argument(ARG_TOKEN)) + } + + private fun probe(endpoint: HostEndpoint, token: String?): ReachableHost { + val cookieJar = MemoryCookieJar() + val client = OkHttpClient.Builder() + .cookieJar(cookieJar) + .connectTimeout(CONNECT_TIMEOUT_S, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_S, TimeUnit.SECONDS) + .pingInterval(0, TimeUnit.MILLISECONDS) + .build() + val host = ReachableHost(endpoint, client) + + val firstStatus = try { + host.get("/live-sessions").first + } catch (e: IOException) { + Assume.assumeNoException( + """ + |SKIPPED — no WebTerm server answered at ${endpoint.baseUrl} (${e.javaClass.simpleName}: ${e.message}). + |Start this repo's own server on the machine hosting the emulator, allowing the guest-side + |origin so the three guarded routes are usable: + | ALLOWED_ORIGINS=${endpoint.originHeader} npm start + |or point the suite at a LAN address instead: + | -Pandroid.testInstrumentationRunnerArguments.$ARG_HOST=http://:3000 + """.trimMargin(), + e, + ) + error("unreachable") // assumeNoException always throws + } + + if (firstStatus == HTTP_UNAUTHORIZED) { + Assume.assumeTrue( + """ + |SKIPPED — ${endpoint.baseUrl} is gated by WEBTERM_TOKEN and no token was supplied. Pass it with + | -Pandroid.testInstrumentationRunnerArguments.$ARG_TOKEN= + |(the token is only exchanged for the webterm_auth cookie; it is never logged or persisted). + """.trimMargin(), + token != null, + ) + val authStatus = host.postJson("/auth", """{"token":${quote(token!!)}}""").first + Assume.assumeTrue( + "SKIPPED — POST /auth at ${endpoint.baseUrl} rejected the supplied token (HTTP $authStatus).", + authStatus == HTTP_NO_CONTENT, + ) + } + + val (status, body) = host.get("/live-sessions") + Assume.assumeTrue( + "SKIPPED — GET /live-sessions at ${endpoint.baseUrl} answered HTTP $status, so this is not a usable WebTerm host.", + status == HTTP_OK, + ) + Assume.assumeTrue( + "SKIPPED — ${endpoint.baseUrl} answered GET /live-sessions with a non-array body, so it is not WebTerm.", + body.trimStart().startsWith("["), + ) + return host + } + + private fun quote(value: String): String = buildString { + append('"') + for (ch in value) { + when (ch) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + else -> append(ch) + } + } + append('"') + } + + /** + * A host that has been *proven* to answer. Exposes only what the E2E tests need, over the one shared + * client (so the auth cookie covers REST and the WS upgrade alike). + */ + internal class ReachableHost(val endpoint: HostEndpoint, val client: OkHttpClient) { + + fun get(path: String): Pair = exchange( + Request.Builder().url(endpoint.baseUrl + path).get().build(), + ) + + /** A GUARDED route: `Origin` stamped byte-equal to [HostEndpoint.originHeader] (plan §4.3). */ + fun postJson(path: String, json: String, origin: String? = null): Pair = exchange( + Request.Builder() + .url(endpoint.baseUrl + path) + .post(json.toByteArray().toRequestBody(JSON)) + .apply { origin?.let { header("Origin", it) } } + .build(), + ) + + fun delete(path: String, origin: String = endpoint.originHeader): Pair = exchange( + Request.Builder().url(endpoint.baseUrl + path).delete().header("Origin", origin).build(), + ) + + private fun exchange(request: Request): Pair = + client.newCall(request).execute().use { it.code to (it.body?.string() ?: "") } + + companion object { + const val OK: Int = HTTP_OK + const val NO_CONTENT: Int = HTTP_NO_CONTENT + const val UNAUTHORIZED: Int = HTTP_UNAUTHORIZED + const val FORBIDDEN: Int = HTTP_FORBIDDEN + const val NOT_FOUND: Int = HTTP_NOT_FOUND + } + } + + /** Minimal in-memory jar — enough to carry `webterm_auth` across REST calls and the WS upgrade. */ + private class MemoryCookieJar : CookieJar { + private val cookies = mutableMapOf() + + override fun loadForRequest(url: HttpUrl): List = synchronized(cookies) { + cookies.values.filter { it.matches(url) } + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) = synchronized(this.cookies) { + cookies.forEach { this.cookies[it.name] = it } + } + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/AlternateScreenScrollRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/AlternateScreenScrollRegressionTest.kt new file mode 100644 index 0000000..c825d04 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/AlternateScreenScrollRegressionTest.kt @@ -0,0 +1,188 @@ +package wang.yaojia.webterm.terminal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ClientMessage + +/** + * REGRESSION — **the CRITICAL one: one finger-swipe inside an alternate-screen TUI killed the process.** + * + * Stock `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; `mTermSession` is not, and it is null forever in this app (plan §6.1). No `TerminalViewClient` + * callback sits anywhere on that path, so installing a client could not help — only taking the gesture + * away from the stock view could. + * + * **Claude Code is an alternate-screen TUI.** That makes this ordinary use, not an edge case: scrolling + * back through a running Claude session was a guaranteed crash. Every test below therefore enters the + * alternate buffer with `ESC [ ? 1 0 4 9 h` FIRST, then drives the real gesture through + * [android.view.View.dispatchTouchEvent] / `dispatchGenericMotionEvent` — the three stock entry points + * (`TerminalView$1.onScroll`, the `TerminalView$1$1` fling runnable, `onGenericMotionEvent`). + * + * "It did not crash" is necessary but not sufficient, so each test also pins the bytes: the alternate- + * buffer branch must emit the DECCKM-correct arrow form, the mouse-tracking branch must emit a mouse + * report and NOT arrows, and the fling must be silent (proof the stock runnable never started). + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class AlternateScreenScrollRegressionTest { + + private lateinit var harness: TerminalTestHarness + + @Before + fun setUp() { + harness = TerminalTestHarness.launch() + harness.awaitAttachSettled() + // ESC [ ? 1 0 4 9 h — enter the alternate screen buffer. This is the state Claude Code, vim, + // htop, less and every git pager run in, and the state that made stock scrolling fatal. + harness.feedAndAwait("\u001b[?1049h", "the alternate screen buffer to be active") { + harness.emulator.isAlternateBufferActive + } + harness.clearSent() + } + + @After + fun tearDown() { + harness.close() + } + + @Test + fun swipeUpInsideTheAlternateBuffer_doesNotCrashAndEmitsDownArrows() { + val claimed = harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX) + + assertTrue( + "the frame must claim the vertical drag — that is what makes stock doScroll unreachable", + claimed, + ) + assertTrue("the alternate buffer must still be active", harness.emulator.isAlternateBufferActive) + val frames = harness.drainSent().filterIsInstance() + assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty()) + assertEquals( + "a finger moving UP the screen scrolls the TUI forward — DPAD_DOWN in CSI form", + emptyList(), + frames.filterNot { it.data == "\u001b[B" }, + ) + } + + @Test + fun swipeDownInsideTheAlternateBuffer_doesNotCrashAndEmitsUpArrows() { + val claimed = harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX) + + assertTrue("the frame must claim the vertical drag", claimed) + val frames = harness.drainSent().filterIsInstance() + assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty()) + assertEquals( + "a finger moving DOWN the screen scrolls back — DPAD_UP in CSI form", + emptyList(), + frames.filterNot { it.data == "\u001b[A" }, + ) + } + + /** + * The arrow FORM has to follow the emulator's live DECCKM bit, because that is what stock + * `handleKeyCode` would have read off `mTermSession.getEmulator()`. A TUI in application-cursor mode + * that received `ESC [ A` instead of `ESC O A` would mis-handle the scroll. + */ + @Test + fun scrollUnderApplicationCursorMode_emitsTheSs3ArrowForm() { + harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") { + harness.emulator.isCursorKeysApplicationMode + } + harness.clearSent() + + harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX) + + val frames = harness.drainSent().filterIsInstance() + assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty()) + assertEquals( + "application-cursor mode must emit the SS3 form", + emptyList(), + frames.filterNot { it.data == "\u001bOA" }, + ) + } + + /** + * `doScroll`'s FIRST branch (offsets 26–53) is mouse tracking, and it must win over the alternate- + * buffer branch. Getting the order wrong would send arrow keys to a program that asked for mouse + * reports — the terminal would appear to ignore the wheel. + */ + @Test + fun wheelUnderMouseTracking_reportsTheWheelAndSendsNoArrowKeys() { + // ESC [ ? 1 0 0 0 h — button-press mouse tracking (what a mouse-aware TUI enables). + harness.feedAndAwait("\u001b[?1000h", "mouse tracking to be active") { + harness.emulator.isMouseTrackingActive + } + harness.clearSent() + + val handled = harness.scrollWheel(up = true) + + assertTrue("the frame must claim the mouse wheel", handled) + val frames = harness.drainSent().filterIsInstance() + assertTrue("the wheel must be reported to the program", frames.isNotEmpty()) + assertEquals( + "mouse tracking must suppress the arrow-key branch entirely", + emptyList(), + frames.filter { it.data == "\u001b[A" || it.data == "\u001bOA" }, + ) + assertTrue( + "a wheel report must be an escape sequence, got ${frames.map { it.data.toCharList() }}", + frames.all { it.data.startsWith("\u001b[") }, + ) + } + + /** Without mouse tracking the wheel takes the same alternate-buffer branch a finger drag does. */ + @Test + fun wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch() { + val handled = harness.scrollWheel(up = true) + + assertTrue("the frame must claim the mouse wheel", handled) + val frames = harness.drainSent().filterIsInstance() + // Stock onGenericMotionEvent scrolls MOUSE_WHEEL_ROWS (3) rows per notch, one key per row. + assertEquals( + "one wheel notch is three rows", + List(WHEEL_ROWS_PER_NOTCH) { ClientMessage.Input("\u001b[A") }, + frames, + ) + } + + /** + * The third stock entry point is the fling runnable `TerminalView$1$1.run`, which keeps calling + * `doScroll` after the finger leaves. It can only start if `mGestureRecognizer` observed the scroll — + * which is exactly what the intercept prevents. So the observable is silence after the drag ends: a + * live fling would keep emitting arrows (and, before the fix, would crash a beat after the gesture, + * which is what made this bug look intermittent). + */ + @Test + fun theStockFlingNeverStarts_soNoBytesArriveAfterTheFingerLeaves() { + harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX, steps = 2) + harness.drainSent() // the drag's own rows + + val afterTheGesture = harness.drainSent(settleMs = FLING_SETTLE_MS) + + assertEquals( + "no fling may continue scrolling after ACTION_UP", + emptyList(), + afterTheGesture, + ) + } + + private fun String.toCharList(): List = map { it.code } + + private companion object { + /** Comfortably past the touch slop and several cell heights on any density. */ + const val DRAG_DISTANCE_PX = 600f + + /** `TerminalScrollGesture.MOUSE_WHEEL_ROWS` — stock scrolls three rows per notch. */ + const val WHEEL_ROWS_PER_NOTCH = 3 + + /** Long enough for a real fling to have produced several frames of scrolling. */ + const val FLING_SETTLE_MS = 800L + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalAutofillRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalAutofillRegressionTest.kt new file mode 100644 index 0000000..7dc6866 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalAutofillRegressionTest.kt @@ -0,0 +1,109 @@ +package wang.yaojia.webterm.terminal + +import android.view.View +import android.view.autofill.AutofillValue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ClientMessage + +/** + * REGRESSION — **a password manager crashed the app just by offering to fill the terminal.** + * + * Stock `TerminalView.autofill(AutofillValue)` (offsets 7–20) writes the filled text straight into + * `mTermSession`, which is null forever here, AND the view advertises itself as fillable + * (`getAutofillType()` = `AUTOFILL_TYPE_TEXT`, `getAutofillValue()` non-null). So on any device with an + * autofill service enabled — the default state for most users — focusing the terminal was enough to arm a + * crash, with no user action beyond accepting the fill. + * + * The fix is not a try/catch around `autofill`: it is keeping the whole subtree OUT of the autofill + * structure, so the framework never builds a fillable node for it. `View.isImportantForAutofill()` walks + * the parent chain and stops at the first `*_EXCLUDE_DESCENDANTS`, and that is precisely the gate + * `AutofillManager` consults before it will notify a service about a view — so it is the gate these tests + * assert, on both the frame and the stock child. + * + * **Deliberately NOT tested here:** the end-to-end loop with a real autofill service (which needs an + * installed, user-selected `AutofillService`) stays on the device-QA checklist. What IS provable on an + * emulator is the framework contract that makes the crash unreachable, plus the belt-and-braces + * assertion that a fill delivered anyway puts nothing on the wire — shell input is exactly the content + * that must never enter an autofill structure (plan §8). + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class TerminalAutofillRegressionTest { + + private lateinit var harness: TerminalTestHarness + + @Before + fun setUp() { + harness = TerminalTestHarness.launch() + harness.awaitAttachSettled() + } + + @After + fun tearDown() { + harness.close() + } + + @Test + fun theTerminalFrameIsExcludedFromTheAutofillStructure() { + val flag = harness.onMain { harness.hostView.importantForAutofill } + val important = harness.onMain { harness.hostView.isImportantForAutofill } + + assertEquals( + "the frame must answer IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS", + View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS, + flag, + ) + assertFalse("the framework must consider the frame unimportant for autofill", important) + } + + /** + * The child is the view that actually carries the crashing `autofill` override, and it is reached + * through the parent-chain walk — which is why the frame overrides the GETTER rather than only + * setting the flag: a later `setImportantForAutofill` cannot quietly re-enable it. + */ + @Test + fun theStockTerminalViewIsExcludedThroughTheParentChain() { + val important = harness.onMain { harness.stockChild.isImportantForAutofill } + + assertFalse( + "the stock TerminalView must be unimportant for autofill via the EXCLUDE_DESCENDANTS walk", + important, + ) + } + + @Test + fun focusingTheTerminalKeepsItExcluded() { + harness.onMain { harness.hostView.requestFocus() } + + assertFalse( + "focus must not make the terminal fillable — focus is what triggered the old crash", + harness.onMain { harness.hostView.isImportantForAutofill }, + ) + assertFalse( + "…nor the stock child", + harness.onMain { harness.stockChild.isImportantForAutofill }, + ) + } + + /** + * Belt and braces: even a fill delivered directly at the frame (bypassing the structure gate) must be + * inert. A password reaching `ClientMessage.Input` would be typed into whatever shell is running. + */ + @Test + fun aFillDeliveredAtTheFrameIsInertAndNeverReachesTheWire() { + harness.onMain { harness.hostView.autofill(AutofillValue.forText("correct-horse-battery-staple")) } + + assertEquals( + "an autofilled value must never be sent to the shell", + emptyList(), + harness.drainSent(), + ) + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalKeyInputRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalKeyInputRegressionTest.kt new file mode 100644 index 0000000..5d5b280 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalKeyInputRegressionTest.kt @@ -0,0 +1,187 @@ +package wang.yaojia.webterm.terminal + +import android.view.KeyEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.ClientMessage + +/** + * REGRESSION — **BLOCKER 1: the app died on the first key press.** + * + * `TerminalView.mClient` was never installed, and stock `onKeyDown` dereferences it with no null guard + * (offset 82–92 → `mClient.onKeyDown(keyCode, event, mTermSession)`), so *any* key — hardware or + * soft-keyboard — was an instant NPE on the UI thread. Installing a client that returned `false` would + * only have moved the crash one line down, into `mTermSession.write(getCharacters())` (offset 149–160), + * because `mTermSession` is null forever here (plan §6.1). The soft-keyboard half was different but no + * better: the stock `TerminalView$2` `InputConnection` routes `commitText` into `inputCodePoint`, which + * early-returns when `mTermSession == null` — everything typed vanished silently. + * + * These tests therefore assert **both** halves of the contract on a real device: nothing crashes, AND the + * exact right bytes reach the wire. A test that only proved "no crash" would pass against the silent + * black hole, which was arguably the worse of the two bugs. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class TerminalKeyInputRegressionTest { + + private lateinit var harness: TerminalTestHarness + + @Before + fun setUp() { + harness = TerminalTestHarness.launch() + // Discard the attach-time grid push before any assertion looks at the wire. + harness.awaitAttachSettled() + } + + @After + fun tearDown() { + harness.close() + } + + // ── Hardware keys ──────────────────────────────────────────────────────────────────────────── + + @Test + fun hardwareEnter_sendsCarriageReturnNotLineFeed() { + val consumed = harness.pressKey(KeyEvent.KEYCODE_ENTER) + + assertTrue("the terminal must consume Enter, not leak it to the Activity", consumed) + // Repo-wide gotcha: a terminal Enter is CR (0x0D), never LF. + assertEquals(ClientMessage.Input("\r"), harness.awaitSent("Enter")) + } + + @Test + fun hardwarePrintableKey_sendsThatCharacter() { + harness.pressKey(KeyEvent.KEYCODE_A) + + assertEquals(ClientMessage.Input("a"), harness.awaitSent("the letter a")) + } + + @Test + fun hardwareShiftedKey_sendsTheCapital() { + harness.pressKey(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON) + + assertEquals(ClientMessage.Input("A"), harness.awaitSent("Shift+A")) + } + + @Test + fun hardwareCtrlC_sendsTheInterruptControlByte() { + harness.pressKey(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON) + + assertEquals(ClientMessage.Input("\u0003"), harness.awaitSent("Ctrl+C")) + } + + @Test + fun hardwareBackspace_sendsDel() { + harness.pressKey(KeyEvent.KEYCODE_DEL) + + assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("Backspace")) + } + + /** + * DECCKM (plan §6.3): arrows must go through Termux's `KeyHandler` so application-cursor mode emits + * `ESC O A` rather than `ESC [ A`. Hardcoding the CSI form breaks vim/htop, so the byte form is + * asserted in BOTH modes against the live emulator bit. + */ + @Test + fun arrowUp_followsTheEmulatorsCursorKeyMode() { + harness.pressKey(KeyEvent.KEYCODE_DPAD_UP) + assertEquals( + "normal cursor mode must emit the CSI form", + ClientMessage.Input("\u001b[A"), + harness.awaitSent("Up in normal cursor mode"), + ) + + // ESC [ ? 1 h — DECCKM on (what full-screen TUIs set). + harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") { harness.emulator.isCursorKeysApplicationMode } + harness.clearSent() + + harness.pressKey(KeyEvent.KEYCODE_DPAD_UP) + assertEquals( + "application cursor mode must emit the SS3 form", + ClientMessage.Input("\u001bOA"), + harness.awaitSent("Up in application cursor mode"), + ) + } + + /** + * BACK must stay the Activity's: `WebTermTerminalViewClient.shouldBackButtonBeMappedToEscape()` + * answers false precisely so stock `onKeyDown` cannot route it into the terminal branch (whose next + * stop is `mTermSession.write`). If this ever returns true the terminal screen becomes a trap the + * user cannot leave. + */ + @Test + fun systemBackKey_isNotSwallowedAndSendsNothing() { + val consumedDown = harness.pressKey(KeyEvent.KEYCODE_BACK) + val consumedUp = harness.releaseKey(KeyEvent.KEYCODE_BACK) + + assertFalse("BACK down must fall through to the Activity", consumedDown) + assertFalse("BACK up must fall through to the Activity", consumedUp) + assertEquals("BACK must put no bytes on the wire", emptyList(), harness.drainSent()) + } + + // ── Soft keyboard (the IME seam) ───────────────────────────────────────────────────────────── + + @Test + fun softKeyboardIsOffered_anInputConnectionForTheTerminal() { + val connection = harness.openInputConnection() + + assertTrue( + "the frame must declare itself a text editor or no IME will attach", + harness.onMain { harness.hostView.onCheckIsTextEditor() }, + ) + // Proves the connection is OURS, not the stock TerminalView$2 black hole: committing text has to + // reach the wire. + harness.onMain { connection.commitText("ls -la", 1) } + assertEquals(ClientMessage.Input("ls -la"), harness.awaitSent("a soft-keyboard commit")) + } + + @Test + fun softKeyboardEnter_sendsCarriageReturn() { + val connection = harness.openInputConnection() + + // Some IMEs deliver Enter as a synthesised key event rather than as text. + harness.onMain { + connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) + } + + assertEquals(ClientMessage.Input("\r"), harness.awaitSent("an IME-synthesised Enter")) + } + + @Test + fun softKeyboardBackspace_sendsDel() { + val connection = harness.openInputConnection() + + harness.onMain { connection.deleteSurroundingText(1, 0) } + + assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("an IME backspace")) + } + + /** + * Non-ASCII is passed through VERBATIM (invariant #9 — no filtering, no normalisation). CJK is the + * case that matters because it arrives via composition, and it is also the one a byte-oriented + * implementation is most likely to mangle. + */ + @Test + fun softKeyboardCjkCommit_isPassedThroughVerbatim() { + val connection = harness.openInputConnection() + + harness.onMain { + connection.setComposingText("中文", 1) + connection.commitText("中文", 1) + } + + val frames = harness.drainSent().filterIsInstance() + assertEquals( + "the committed CJK text must reach the wire unmodified", + "中文", + frames.joinToString(separator = "") { it.data }, + ) + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeDrawRaceRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeDrawRaceRegressionTest.kt new file mode 100644 index 0000000..aa70b60 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeDrawRaceRegressionTest.kt @@ -0,0 +1,155 @@ +package wang.yaojia.webterm.terminal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.terminalview.RemoteTerminalSession + +/** + * REGRESSION for a **defect this suite FOUND, and which is NOT yet fixed** (2026-07-30). + * + * ## The defect + * `RemoteTerminalSession.onResize` calls `emulator.resize(cols, rows)` on its confined *writer* thread. + * The stock `TerminalRenderer` reads that same `TerminalBuffer` on the **UI thread** during `onDraw`, and + * it caches `mEmulator.mColumns` / `mRows` at the top of `render()` before walking the rows. So a regrid + * landing mid-frame tears the buffer out from under the renderer, which then indexes rows that have + * already been reallocated to the other size. Both shapes were observed as FATAL exceptions on the main + * thread on the `webterm` AVD (android-35): + * + * ``` + * FATAL EXCEPTION: main + * java.lang.ArrayIndexOutOfBoundsException: length=31; index=31 + * at com.termux.terminal.TerminalRow.getStyle(TerminalRow.java:244) + * at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:104) + * at com.termux.view.TerminalView.onDraw(TerminalView.java:921) + * + * FATAL EXCEPTION: main + * java.lang.IllegalArgumentException: extRow=19, mScreenRows=18, mActiveTranscriptRows=0 + * at com.termux.terminal.TerminalBuffer.externalToInternalRow(TerminalBuffer.java:175) + * at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:83) + * at com.termux.view.TerminalView.onDraw(TerminalView.java:921) + * ``` + * + * ## Why it matters, and how reliably it reproduces — stated precisely + * The window is not exotic: **every** session attach performs an 80x24 -> real-grid reflow (the server + * spawns the PTY at 80x24 and the client corrects it), and every rotation performs another, both while + * frames are being drawn. `TerminalBuffer.resize` reallocates every transcript row, so at production's + * 10 000-row transcript the mutation takes milliseconds rather than microseconds. + * + * **Reproduction is load-dependent, and a green run here does NOT mean the bug is gone.** Measured on the + * `webterm` AVD: + * - Under load (host memory starved, emulator thrashing) it fired constantly: it aborted roughly one + * instrumentation setup in three, hit an alternating-regrid loop within one or two iterations, and + * surfaced BOTH crash shapes above across about six separate runs. + * - On a freshly rebooted, idle emulator, 60 iterations with a concurrent output burst each pass cleanly: + * the reflow simply finishes inside a gap between frames. + * + * That is the signature of a real, unsynchronised data race rather than a deterministic bug — and load is + * exactly the condition a user meets: a mid-range phone rotating while a busy Claude session streams, or a + * cold start replaying a multi-MB ring buffer. So this test is a **stress probe, not a decider**: it fails + * loudly when it catches the tear and must not be read as an all-clear when it does not. The finding stands + * on the captured stack traces above, not on this test's verdict. + * + * It is invisible to the 900-strong JVM suite for the usual reason - no JVM test draws a `View` - and + * invisible to a manual emulator pass that only reaches the pairing screen, because it needs a bound + * session. + * + * ## The fix (verified here before being reported, and NOT applied — `:terminal-view` is not this + * agent's to edit) + * Do the reflow on the renderer's own thread: inside the confined consumer, wrap the mutation as + * `withContext(mainDispatcher) { emulator.resize(cols, rows) }`. That keeps command ORDER intact (the + * consumer still processes one command at a time, so a resize cannot overtake an append) while making the + * mutation mutually exclusive with `onDraw`. It is also what Termux itself does — its own + * `TerminalView.updateSize()` resizes from the UI thread. + * + * **Control experiment, run on this emulator:** 60 alternating regrids driven from the *main* thread with + * `invalidate()` after each one → zero crashes. The identical loop with the regrid on the confined thread + * → FATAL within two iterations. So the hop is sufficient, and the confinement is the cause. + * + * ## Reading this test + * A FAILURE (a main-thread FATAL that also aborts the instrumentation run, which is what the defect does to + * a user's app) is a positive catch: the bug is still there. A PASS means only that this run did not win the + * race. It deliberately uses the production transcript depth + * ([RemoteTerminalSession.TRANSCRIPT_ROWS]) and a concurrent output burst per iteration, which are the two + * knobs that widen the window. Once the reflow is hopped onto the main thread the test becomes a genuine + * decider, because then no interleaving exists that can tear the buffer. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class TerminalResizeDrawRaceRegressionTest { + + private lateinit var harness: TerminalTestHarness + + @Before + fun setUp() { + // Production scrollback depth on purpose: it is what makes the reallocation — and therefore the + // race window — the real size. + harness = TerminalTestHarness.launch(transcriptRows = RemoteTerminalSession.TRANSCRIPT_ROWS) + harness.awaitAttachSettled() + } + + @After + fun tearDown() { + harness.close() + } + + /** + * Alternate between two grids while forcing a redraw after each change — i.e. what a user does by + * rotating the device, folding a foldable, or dragging a multi-window divider. Every iteration must + * survive; a single torn frame is a dead app. + */ + @Test + fun regriddingWhileTheRendererDraws_neverTearsTheBuffer() { + repeat(REGRID_ITERATIONS) { iteration -> + // Keep the confined writer busy so the reflow is queued behind real appends and lands at an + // unpredictable point relative to the frame pipeline. Without this the reflow tends to complete + // in one gap between frames on a fast, idle device and the tear is missed — the race is + // probabilistic, and this is what makes the probability worth testing. + harness.session.feedRemote(OUTPUT_BURST.toByteArray(Charsets.UTF_8)) + val wide = iteration % 2 == 0 + harness.layoutTo( + widthPx = if (wide) WIDE_PX else NARROW_PX, + heightPx = if (wide) SHORT_PX else TALL_PX, + ) + harness.onMain { + harness.hostView.invalidate() + harness.stockChild.invalidate() + } + harness.awaitTrue("iteration $iteration to reflow the emulator") { + harness.emulator.mColumns > 0 && harness.emulator.mRows > 0 + } + } + + // Reaching here at all is the assertion: the failure mode is a process-killing FATAL on the main + // thread, not a false return. This last check adds a coherence probe that indexes the buffer the + // same way the renderer does — a torn buffer throws out of it rather than answering. + val readBack = harness.emulator.screen.getSelectedText( + 0, + 0, + harness.emulator.mColumns - 1, + harness.emulator.mRows - 1, + ) + assertNotNull("the buffer must still be readable at the emulator's advertised dims", readBack) + } + + private companion object { + /** + * The tear is probabilistic: it needs the reflow to land while a frame is mid-flight, which on an + * idle emulator sometimes never happens. 60 iterations with a concurrent output burst each is what + * made it reproduce, rather than the 24 quiet ones that passed on a freshly booted device. + */ + const val REGRID_ITERATIONS = 60 + + /** ~8 KB of real terminal output per iteration: enough to keep the confined writer working. */ + val OUTPUT_BURST: String = (1..64).joinToString("") { "webterm-race-$it " + "-".repeat(64) + "\r\n" } + + const val WIDE_PX = 900 + const val NARROW_PX = 500 + const val SHORT_PX = 700 + const val TALL_PX = 900 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeRegressionTest.kt new file mode 100644 index 0000000..cf360d3 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeRegressionTest.kt @@ -0,0 +1,225 @@ +package wang.yaojia.webterm.terminal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.terminalview.RemoteTerminalSession +import wang.yaojia.webterm.wire.ClientMessage + +/** + * REGRESSION — **`resize` had zero call sites, so the PTY stayed at 80×24 forever.** + * + * `RemoteTerminalSession.updateSize` existed and was unit-tested, and nothing called it. The stock + * fallback cannot help either: `TerminalView.updateSize()` early-returns when `mTermSession == null` + * (offsets 18–25), permanently our case. The visible symptom was every full-screen TUI — Claude Code + * included — rendering into an 80×24 box in the corner of a phone screen, and never reflowing on rotation. + * + * A JVM test cannot catch this class of bug at all: the arithmetic was already correct and tested + * ([wang.yaojia.webterm.terminalview.TerminalGridMath]); what was missing was a real layout pass, real + * cell metrics from a real `TerminalRenderer`, and a real wire. All three exist here. + * + * ### How "the expected grid" is asserted without reading private font metrics + * `TerminalRenderer`'s metrics are only reachable through `:terminal-view`'s `internal` API, and the + * Termux artifact is `implementation`-scoped so its types are off `:app`'s classpath. Rather than + * reflect (which silently rots) the tests assert the **documented formula's own algebra**, which needs no + * metric value: with zero padding, `cols = floor(W / fontWidth)`, so doubling the width must give + * `floor(2W / fontWidth) ∈ {2·cols, 2·cols + 1}` — and nothing else. Same for rows. That pins the grid to + * the plan §6.4 formula rather than merely checking it is "some positive number", and it fails loudly if + * the padding assumption ever stops holding (asserted separately). + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class TerminalResizeRegressionTest { + + private lateinit var harness: TerminalTestHarness + + @Before + fun setUp() { + harness = TerminalTestHarness.launch() + } + + @After + fun tearDown() { + harness.close() + } + + @Test + fun theStockViewHasNoPadding_whichTheGridAlgebraBelowRelies_on() { + val horizontal = harness.onMain { harness.stockChild.paddingLeft } + val vertical = harness.onMain { harness.stockChild.paddingTop } + + assertEquals("the grid algebra in this class assumes zero horizontal padding", 0, horizontal) + assertEquals("the grid algebra in this class assumes zero vertical padding", 0, vertical) + } + + @Test + fun theFirstLayoutPushesARealGridToTheWireAndToTheEmulator() { + val resize = harness.firstResize() + + assertTrue("cols must be a real grid, not the 80x24 default box", resize.cols > 1) + assertTrue("rows must be a real grid, not the 80x24 default box", resize.rows > 1) + assertEquals( + "the local emulator must be reflowed to exactly the dims that went on the wire", + resize.cols to resize.rows, + harness.emulator.mColumns to harness.emulator.mRows, + ) + assertTrue( + "the grid must be within the protocol's validated range", + resize.cols in RESIZE_RANGE && resize.rows in RESIZE_RANGE, + ) + } + + /** + * The §6.4 formula, checked by its own algebra: doubling the pixel box must double the grid to within + * the one cell a `floor` can absorb. A driver that ignored the real metrics (e.g. one that resent the + * default 80×24, or one that divided by a re-measured `Paint`) cannot satisfy this. + */ + @Test + fun doublingTheViewboxDoublesTheGrid_perThePlanFormula() { + val base = harness.firstResize() + + harness.clearSent() + harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX * 2, TerminalTestHarness.DEFAULT_HEIGHT_PX * 2) + val doubled = harness.awaitResize("the grid after doubling the viewbox") + + assertTrue( + "cols=floor(W/fw) ⇒ floor(2W/fw) must be 2·cols or 2·cols+1, was ${doubled.cols} for base ${base.cols}", + doubled.cols == base.cols * 2 || doubled.cols == base.cols * 2 + 1, + ) + assertTrue( + "rows=H/ls ⇒ 2H/ls must be 2·rows or 2·rows+1, was ${doubled.rows} for base ${base.rows}", + doubled.rows == base.rows * 2 || doubled.rows == base.rows * 2 + 1, + ) + assertEquals( + "the emulator must follow the wire", + doubled.cols to doubled.rows, + harness.emulator.mColumns to harness.emulator.mRows, + ) + } + + /** + * A rotation is, to this path, a layout pass with the dimensions swapped — and it must produce a NEW + * grid rather than keep the portrait one (the "does not reflow on rotation" half of the bug). + */ + @Test + fun swappingTheViewboxDimensions_reflowsToATransposedGrid() { + val portrait = harness.firstResize() + + harness.clearSent() + harness.layoutTo(TerminalTestHarness.DEFAULT_HEIGHT_PX, TerminalTestHarness.DEFAULT_WIDTH_PX) + val landscape = harness.awaitResize("the grid after swapping width and height") + + assertTrue("a wider box must give more columns", landscape.cols > portrait.cols) + assertTrue("a shorter box must give fewer rows", landscape.rows < portrait.rows) + } + + /** + * Layout fires far more often than the grid changes (every IME show/hide, inset change and frame of a + * fold drag). Each one becoming a wire frame would mean a SIGWINCH storm, so an unchanged grid must be + * silent — the dedup that makes the forced re-assert below necessary in the first place. + */ + @Test + fun relayoutAtTheSameSize_sendsNothing() { + harness.firstResize() + harness.clearSent() + + harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX, TerminalTestHarness.DEFAULT_HEIGHT_PX) + + assertEquals( + "an unchanged grid must not reach the wire", + emptyList(), + harness.drainSent(), + ) + } + + /** + * **The §6.4 double-dedup bug.** A shared PTY has exactly ONE size, so a device returning to the + * foreground has to re-assert dims it has already sent in order to take the size back from whichever + * device wrote last. Two dedups sit in series on that path — `TerminalResizeDriver.lastGrid` and + * `RemoteTerminalSession.lastSentDims` — and BOTH survive a rebind, so an unflagged re-push dies + * silently and the returning device stays letterboxed forever. + * + * Rebinding the session is production's own trigger (`RemoteTerminalView.session`'s setter and + * `bindEmulator` both call `reassertLastGrid()`), so this test performs exactly that and requires the + * identical grid to reach the wire a second time. + */ + @Test + fun rebindingTheSession_reassertsTheSameGridOntoTheWire() { + val before = harness.firstResize() + harness.clearSent() + + // What a device-switch / pane-show does: re-bind, which must force the memoised grid through both + // dedups even though not one pixel changed. + harness.onMain { harness.view.session = harness.session } + + val reasserted = harness.awaitResize("the forced re-assert after a rebind") + assertEquals( + "the re-assert must carry the SAME grid — it is a claim on the shared PTY size, not a change", + before, + reasserted, + ) + } + + /** + * The same reclaim through the other production trigger: a brand-new session (a real background → + * foreground cycle bumps the generation and builds a fresh emulator at the server's 80×24 default). + * The view already knows the real grid, so it must push it at the new session immediately rather than + * wait for a layout pass that will never come. + */ + @Test + fun attachingAFreshSession_pushesTheMeasuredGridImmediately() { + val measured = harness.firstResize() + + val replacementSent = java.util.concurrent.LinkedBlockingQueue() + val replacement = harness.onMain { + RemoteTerminalSession(engineSend = { message -> replacementSent.put(message) }) + } + try { + harness.onMain { replacement.attachView(harness.view) } + + val pushed = harness.awaitTrueValue("the fresh session to be told the real grid") { + replacementSent.poll()?.let { it as? ClientMessage.Resize } + } + assertEquals( + "a fresh emulator starts at the server's 80x24 default and must be corrected at once", + measured, + pushed, + ) + assertEquals( + "the fresh emulator must be reflowed too", + measured.cols to measured.rows, + replacement.emulator.mColumns to replacement.emulator.mRows, + ) + } finally { + harness.onMain { replacement.close() } + } + } + + // ── helpers ────────────────────────────────────────────────────────────────────────────────── + + /** The attach-time grid push — the first `Resize` the initial layout produced. */ + private fun TerminalTestHarness.firstResize(): ClientMessage.Resize = awaitAttachSettled() + + private fun TerminalTestHarness.awaitResize(what: String): ClientMessage.Resize { + var last: ClientMessage? = null + repeat(MAX_FRAMES_SCANNED) { + val message = awaitSent(what) + last = message + if (message is ClientMessage.Resize) return message + } + throw AssertionError("no Resize frame arrived while waiting for $what; last frame was $last") + } + + private companion object { + /** `WireConstants.RESIZE_RANGE` — the server silently drops anything outside it. */ + val RESIZE_RANGE = 1..1000 + + /** Guards the scan loop from spinning if the wire only ever carries non-Resize frames. */ + const val MAX_FRAMES_SCANNED = 8 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalTestHarness.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalTestHarness.kt new file mode 100644 index 0000000..ec6213f --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalTestHarness.kt @@ -0,0 +1,354 @@ +package wang.yaojia.webterm.terminal + +import android.app.Activity +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import com.termux.terminal.TerminalEmulator +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import wang.yaojia.webterm.terminalview.RemoteTerminalSession +import wang.yaojia.webterm.terminalview.RemoteTerminalView +import wang.yaojia.webterm.wire.ClientMessage +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** + * The device-side rig for the four 2026-07-30 crash regressions. + * + * ### Why these tests can only exist here + * Every defect this rig covers lived in a real `android.view.View` (`TerminalView.onKeyDown`, + * `doScroll`, `autofill`, `updateSize`). The 900-odd JVM tests could not see any of them because no JVM + * test instantiates a View, inflates an `InputConnection`, dispatches a `MotionEvent` or runs a layout + * pass — which is exactly how three crash-on-first-interaction bugs shipped behind a green suite. So the + * rig builds the REAL [RemoteTerminalView] inside a REAL Activity and drives it through the REAL + * framework entry points. Nothing is faked except the wire: [sent] captures the [ClientMessage]s that + * would have gone to the server, which is the only observable a regression can be asserted against + * without a host. + * + * ### Threading contract + * The View and the session's bind path are main-thread-only, so every mutation goes through + * [onMain]. The test body itself runs on the instrumentation thread, which is what lets [awaitSent] and + * [awaitTrue] block: the confined emulator-writer thread and the main looper both stay free to make + * progress while the test waits. Blocking on the main thread instead would deadlock the very handoff + * under test. + */ +internal class TerminalTestHarness private constructor( + private val scenario: ActivityScenario, + val view: RemoteTerminalView, + val session: RemoteTerminalSession, + val root: FrameLayout, + private val sent: LinkedBlockingQueue, +) { + /** The frame that owns focus + the IME contract; the stock Termux view is its only child. */ + val hostView: View get() = view.terminalView + + /** The stock `com.termux.view.TerminalView`, reachable only as a plain [View]: `:terminal-view` + * scopes the Termux artifact as `implementation`, so its type is off `:app`'s classpath. Every + * assertion this rig needs (`isImportantForAutofill`, padding) is plain `View` API anyway. */ + val stockChild: View get() = (hostView as ViewGroup).getChildAt(0) + + val emulator: TerminalEmulator get() = session.emulator + + // ── Driving the view ───────────────────────────────────────────────────────────────────────── + + fun onMain(block: (Activity) -> T): T { + var result: T? = null + var failure: Throwable? = null + val done = CountDownLatch(1) + scenario.onActivity { activity -> + try { + result = block(activity) + } catch (t: Throwable) { + failure = t + } finally { + done.countDown() + } + } + assertTrue("the main thread never ran the block within $MAIN_SYNC_TIMEOUT_MS ms", + done.await(MAIN_SYNC_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + failure?.let { throw it } + @Suppress("UNCHECKED_CAST") + return result as T + } + + /** Resize the hosted view to an exact pixel box and wait for the layout pass to land. */ + fun layoutTo(widthPx: Int, heightPx: Int) { + onMain { + hostView.layoutParams = FrameLayout.LayoutParams(widthPx, heightPx) + hostView.requestLayout() + } + awaitTrue("the view never laid out at ${widthPx}x$heightPx") { + onMain { hostView.width == widthPx && hostView.height == heightPx } + } + } + + /** Dispatch a key through the framework entry point a physical keyboard uses. */ + fun pressKey(keyCode: Int, metaState: Int = 0): Boolean = onMain { + val down = KeyEvent( + /* downTime = */ 0L, + /* eventTime = */ 0L, + KeyEvent.ACTION_DOWN, + keyCode, + /* repeat = */ 0, + metaState, + ) + hostView.dispatchKeyEvent(down) + } + + fun releaseKey(keyCode: Int, metaState: Int = 0): Boolean = onMain { + val up = KeyEvent(0L, 0L, KeyEvent.ACTION_UP, keyCode, 0, metaState) + hostView.dispatchKeyEvent(up) + } + + /** The soft-keyboard seam: the same `InputConnection` an IME would be handed. */ + fun openInputConnection(): InputConnection = onMain { + val editorInfo = EditorInfo() + val connection = hostView.onCreateInputConnection(editorInfo) + assertNotNull("onCreateInputConnection returned null — the IME would have no target", connection) + connection!! + } + + /** + * A vertical finger drag, dispatched exactly as the framework would: one DOWN, [steps] MOVEs and one + * UP, all through [View.dispatchTouchEvent] so `onInterceptTouchEvent` runs for real. + * + * @param totalDeltaPx negative moves the finger UP the screen (content scrolls forward), positive + * moves it DOWN (content scrolls back into history). + * @return whether the frame claimed the gesture — true is what makes the crashing stock + * `doScroll` / fling path unreachable rather than merely unlikely. + */ + fun dragVertically(startY: Float, totalDeltaPx: Float, steps: Int = DRAG_STEPS): Boolean { + val x = 10f + var claimed = false + onMain { + val down = motionEvent(MotionEvent.ACTION_DOWN, x, startY) + hostView.dispatchTouchEvent(down) + down.recycle() + for (step in 1..steps) { + val y = startY + totalDeltaPx * step / steps + val move = motionEvent(MotionEvent.ACTION_MOVE, x, y) + // dispatchTouchEvent returns the ViewGroup's verdict; once the frame intercepts, the + // remainder is routed to its own onTouchEvent, which keeps returning true. + claimed = hostView.dispatchTouchEvent(move) || claimed + move.recycle() + } + val up = motionEvent(MotionEvent.ACTION_UP, x, startY + totalDeltaPx) + hostView.dispatchTouchEvent(up) + up.recycle() + } + return claimed + } + + /** One external-mouse wheel notch — the second stock entry into the crashing `doScroll`. */ + fun scrollWheel(up: Boolean): Boolean = onMain { + val event = wheelEvent(if (up) 1f else -1f) + try { + hostView.dispatchGenericMotionEvent(event) + } finally { + event.recycle() + } + } + + // ── Observing ──────────────────────────────────────────────────────────────────────────────── + + /** The next message that reached the (fake) wire, or fail after [WIRE_TIMEOUT_MS]. */ + fun awaitSent(what: String): ClientMessage { + val message = sent.poll(WIRE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + assertNotNull("nothing reached the wire within $WIRE_TIMEOUT_MS ms while waiting for $what", message) + return message!! + } + + /** Every message that reached the wire within [settleMs] (used to assert "and nothing else"). */ + fun drainSent(settleMs: Long = SETTLE_MS): List { + Thread.sleep(settleMs) + val drained = ArrayList() + sent.drainTo(drained) + return drained + } + + fun clearSent() { + sent.clear() + } + + /** Feed remote bytes and block until the confined writer has applied them. */ + fun feedAndAwait(bytes: String, description: String, predicate: () -> Boolean) { + session.feedRemote(bytes.toByteArray(Charsets.UTF_8)) + awaitTrue(description, predicate) + } + + fun awaitTrue(what: String, predicate: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS) + while (System.nanoTime() < deadline) { + if (predicate()) return + Thread.sleep(POLL_INTERVAL_MS) + } + assertTrue("timed out after $AWAIT_TIMEOUT_MS ms waiting for: $what", predicate()) + } + + fun close() { + onMain { session.close() } + scenario.close() + } + + private fun motionEvent(action: Int, x: Float, y: Float): MotionEvent = + MotionEvent.obtain(DOWN_TIME, DOWN_TIME, action, x, y, 0) + + private fun wheelEvent(vScroll: Float): MotionEvent { + val properties = arrayOf( + MotionEvent.PointerProperties().apply { + id = 0 + toolType = MotionEvent.TOOL_TYPE_MOUSE + }, + ) + val coords = arrayOf( + MotionEvent.PointerCoords().apply { + x = 10f + y = 10f + setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll) + }, + ) + return MotionEvent.obtain( + /* downTime = */ DOWN_TIME, + /* eventTime = */ DOWN_TIME, + MotionEvent.ACTION_SCROLL, + /* pointerCount = */ 1, + properties, + coords, + /* metaState = */ 0, + /* buttonState = */ 0, + /* xPrecision = */ 1f, + /* yPrecision = */ 1f, + /* deviceId = */ 0, + /* edgeFlags = */ 0, + android.view.InputDevice.SOURCE_MOUSE, + /* flags = */ 0, + ) + } + + /** + * Block until the attach-time grid push (80×24 → the real grid) has landed on the wire AND the wire + * has gone quiet, then discard those frames. + * + * Without this, `clearSent()` in a `@Before` races the confined writer: the attach pushes the grid + * asynchronously (twice — `attachView`'s session setter and `bindEmulator` both force a re-assert), + * so a `Resize` can arrive *after* the clear and be mistaken for the first frame a test caused. Two + * of the very first runs of this suite failed exactly that way. + */ + fun awaitAttachSettled(): ClientMessage.Resize { + val grid = awaitTrueValue("the attach-time grid to reach the wire") { + sent.poll() as? ClientMessage.Resize + } + drainSent(settleMs = ATTACH_SETTLE_MS) + return grid + } + + fun awaitTrueValue(what: String, produce: () -> T?): T { + var found: T? = null + awaitTrue(what) { + found = produce() + found != null + } + return found!! + } + + companion object { + /** The whole terminal viewport for the initial layout; individual tests re-layout as needed. */ + const val DEFAULT_WIDTH_PX: Int = 600 + const val DEFAULT_HEIGHT_PX: Int = 800 + + private const val DOWN_TIME = 1_000L + private const val DRAG_STEPS = 8 + private const val MAIN_SYNC_TIMEOUT_MS = 5_000L + private const val WIRE_TIMEOUT_MS = 3_000L + private const val AWAIT_TIMEOUT_MS = 5_000L + private const val POLL_INTERVAL_MS = 20L + private const val SETTLE_MS = 300L + private const val ATTACH_SETTLE_MS = 500L + + /** + * Build the rig: a [ComponentActivity] (supplied by `ui-test-manifest`), a root frame, the real + * [RemoteTerminalView] added to it at [DEFAULT_WIDTH_PX]×[DEFAULT_HEIGHT_PX], and a + * [RemoteTerminalSession] attached to it. + * + * ### Why the view is laid out BEFORE the session attaches + * This is the §6.6 config-change rebind order (a laid-out view, a session bound to it) rather than + * the cold-start order, and it is chosen for a specific reason: it keeps the attach-time 80×24 → + * real-grid reflow *ahead* of the moment the emulator becomes visible to the stock renderer. + * `attachView` queues the forced re-assert (from the session setter) before the Bind command, and + * the confined consumer is FIFO, so the reallocation completes while no frame can be reading the + * buffer. `bindEmulator`'s second re-assert then carries the same dims, which Termux's + * `TerminalEmulator.resize` short-circuits, so it reallocates nothing. + * + * Doing it the other way round — attach first, let the first layout pass fire afterwards — puts + * the reflow on the confined writer thread while the renderer is already drawing, which is the + * still-unfixed defect [TerminalResizeDrawRaceRegressionTest] documents. That defect is real and + * this ordering does not paper over it: it is exercised deliberately by that test and by every + * relayout in [TerminalResizeRegressionTest]. Ordering it away here simply stops a `:terminal-view` + * defect from killing the process during the *setup* of key, scroll and autofill tests that are + * not about resize at all. + */ + fun launch(transcriptRows: Int = RemoteTerminalSession.TRANSCRIPT_ROWS): TerminalTestHarness { + val sent = LinkedBlockingQueue() + val scenario = ActivityScenario.launch(ComponentActivity::class.java) + var view: RemoteTerminalView? = null + var root: FrameLayout? = null + scenario.onActivity { activity -> + val terminal = RemoteTerminalView(activity) + val frame = FrameLayout(activity) + frame.addView( + terminal.terminalView, + FrameLayout.LayoutParams(DEFAULT_WIDTH_PX, DEFAULT_HEIGHT_PX), + ) + activity.setContentView(frame) + view = terminal + root = frame + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + val terminal = view!! + // Wait for the real layout pass. With no session bound yet, TerminalResizeDriver memoises the + // measured grid and pushes nothing (`session?.updateSize` is a no-op) — so nothing has to be + // reflowed while a frame is in flight. + var laidOut = false + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS) + while (!laidOut && System.nanoTime() < deadline) { + val done = CountDownLatch(1) + scenario.onActivity { laidOut = terminal.terminalView.width == DEFAULT_WIDTH_PX } + done.countDown() + if (!laidOut) Thread.sleep(POLL_INTERVAL_MS) + } + assertTrue("the terminal never laid out at $DEFAULT_WIDTH_PX px wide", laidOut) + + var session: RemoteTerminalSession? = null + scenario.onActivity { + val remote = RemoteTerminalSession( + engineSend = { message -> sent.put(message) }, + transcriptRows = transcriptRows, + ) + remote.attachView(terminal) + session = remote + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + val harness = TerminalTestHarness(scenario, terminal, session!!, root!!, sent) + // Focus is a PRECONDITION for the key path, not a convenience: `ViewGroup.dispatchKeyEvent` + // only calls `super.dispatchKeyEvent` (hence `onKeyDown`) when the group itself is FOCUSED + // and laid out, and it has no focusable children here (`FOCUS_BLOCK_DESCENDANTS`). Production + // satisfies this the same way — `dispatchTouchEvent` claims focus on ACTION_DOWN and + // `showSoftKeyboard()` calls `requestFocus()` — so an unfocused terminal receiving no hardware + // keys is correct framework behaviour, not a defect. Asserting it here keeps a silent focus + // regression from turning the key tests into vacuous passes. + harness.awaitTrue("the terminal frame to take focus") { + harness.onMain { harness.hostView.requestFocus() || harness.hostView.isFocused } + } + return harness + } + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/GateSurfacesUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/GateSurfacesUiTest.kt new file mode 100644 index 0000000..7983577 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/GateSurfacesUiTest.kt @@ -0,0 +1,159 @@ +package wang.yaojia.webterm.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.components.GateBanner +import wang.yaojia.webterm.components.PlanGateSheet +import wang.yaojia.webterm.designsystem.WebTermTheme +import wang.yaojia.webterm.session.GateState +import wang.yaojia.webterm.viewmodels.GateDecision +import wang.yaojia.webterm.wire.GateKind + +/** + * **Plan §7 Compose UI tests — the gate surfaces.** + * + * These are the two surfaces a remote approval is made from, so the load-bearing contract is not the layout + * (device-QA) but that **each affordance reports the decision it is labelled with, carrying the epoch of the + * gate that was on screen**. The epoch is the stale-guard seam: `GateViewModel.decide` drops a tap whose + * epoch is no longer live, which is what stops a tap landing on a gate the user never saw. A test that only + * checked the copy would let a swapped-callback bug through — approving when the user pressed reject is the + * single worst failure this app can have. + * + * The server-supplied `detail` is also asserted to render, because it must render as INERT text (plan §8) — + * untrusted tool/OSC content with no markdown and no autolink. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class GateSurfacesUiTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun toolGateBanner_reportsApproveWithTheOnScreenEpoch() { + val decisions = mutableListOf>() + compose.setContent { + WebTermTheme { + GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e }) + } + } + + compose.onNodeWithText(DETAIL).assertIsDisplayed() + compose.onNodeWithText(APPROVE).performClick() + + assertEquals(listOf(GateDecision.APPROVE to TOOL_EPOCH), decisions) + } + + @Test + fun toolGateBanner_reportsRejectWithTheOnScreenEpoch() { + val decisions = mutableListOf>() + compose.setContent { + WebTermTheme { + GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e }) + } + } + + compose.onNodeWithText(REJECT).performClick() + + assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions) + } + + /** + * A previewless gate is a NORMAL state, not an error: the server only sends a preview for a held + * Bash/Edit-family tool, and a malformed one decodes to null rather than costing us the gate. So the + * banner must still be fully usable with no preview. + */ + @Test + fun toolGateBanner_withNoPreviewIsStillFullyDecidable() { + val decisions = mutableListOf>() + compose.setContent { + WebTermTheme { + GateBanner(gate = toolGate.copy(preview = null), onDecide = { d, e -> decisions += d to e }) + } + } + + compose.onNodeWithText(APPROVE).assertIsDisplayed() + compose.onNodeWithText(REJECT).performClick() + + assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions) + } + + /** + * The plan gate is THREE-way, and the third option is the one most easily got wrong: 「继续计划」 keeps + * planning, i.e. it wires to `reject`, not to a second kind of approval. + */ + @Test + fun planGateSheet_offersAllThreeDecisionsWithTheOnScreenEpoch() { + val decisions = mutableListOf>() + compose.setContent { + WebTermTheme { + PlanGateSheet(gate = planGate, onDecide = { d, e -> decisions += d to e }, onDismiss = {}) + } + } + + compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed() + compose.onNodeWithText(PLAN_DETAIL).assertIsDisplayed() + + compose.onNodeWithText(ACCEPT_EDITS).performClick() + compose.onNodeWithText(KEEP_PLANNING).performClick() + compose.onNodeWithText(APPROVE, useUnmergedTree = false).performClick() + + assertEquals( + listOf( + GateDecision.ACCEPT_EDITS to PLAN_EPOCH, + GateDecision.REJECT to PLAN_EPOCH, + GateDecision.APPROVE to PLAN_EPOCH, + ), + decisions, + ) + } + + /** + * Dismissing the sheet (scrim tap / back) must resolve NOTHING — the gate stays held until an explicit + * decision. Silently treating a dismissal as a reject would cancel a user's work behind their back. + */ + @Test + fun planGateSheet_dismissalDecidesNothing() { + val decisions = mutableListOf>() + var dismissals = 0 + compose.setContent { + WebTermTheme { + PlanGateSheet( + gate = planGate, + onDecide = { d, e -> decisions += d to e }, + onDismiss = { dismissals += 1 }, + ) + } + } + + // The sheet is on screen and no decision has been reported; the dismiss callback is the only other + // exit, and it is wired to a handler that resolves nothing. + compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed() + assertEquals(emptyList>(), decisions) + assertEquals(0, dismissals) + } + + private companion object { + const val TOOL_EPOCH = 7 + const val PLAN_EPOCH = 11 + const val DETAIL = "Bash" + const val PLAN_DETAIL = "Refactor the auth module into 3 files." + + const val APPROVE = "批准" + const val REJECT = "拒绝" + const val ACCEPT_EDITS = "批准并自动接受" + const val KEEP_PLANNING = "继续计划" + const val PLAN_TITLE = "批准执行计划" + + val toolGate = GateState(kind = GateKind.TOOL, detail = DETAIL, epoch = TOOL_EPOCH) + val planGate = GateState(kind = GateKind.PLAN, detail = PLAN_DETAIL, epoch = PLAN_EPOCH) + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/KeyBarUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/KeyBarUiTest.kt new file mode 100644 index 0000000..a57a110 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/KeyBarUiTest.kt @@ -0,0 +1,150 @@ +package wang.yaojia.webterm.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.components.KeyBar +import wang.yaojia.webterm.designsystem.WebTermTheme + +/** + * **Plan §7 Compose UI test — the mobile key-bar, pinned above the IME.** + * + * The key-bar is how a phone produces the keys Claude Code needs and a soft keyboard cannot: Esc, ⇧Tab, + * arrows, Ctrl-chords. Its defining property is that a tap goes **straight to the wire**, bypassing the IME + * entirely (mirroring the web client's `ws.send` bypass) — that is what stops the soft keyboard popping up + * every time the user dismisses a Claude prompt. So the assertions are on the bytes each button emits, not + * on its looks. + * + * What stays device-QA: that the bar is *visually* above the IME. `WindowInsets.ime` reports zero in an + * instrumented test with no keyboard raised, so an assertion about its on-screen position would be + * measuring the absence of a keyboard, not the inset behaviour. The union-with-navigation-bars fallback that + * makes the bar sit above the nav bar when the keyboard is hidden IS exercised here (the bar renders and is + * hittable), and the real above-the-IME check is on the checklist. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class KeyBarUiTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun escTap_sendsTheEscapeByteDirectly() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("Esc").performClick() + + assertEquals(listOf("\u001b"), sent) + } + + @Test + fun enterTap_sendsCarriageReturnNotLineFeed() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("⏎").performClick() + + // The repo-wide gotcha, on the surface a phone user hits most often. + assertEquals(listOf("\r"), sent) + } + + @Test + fun ctrlCTap_sendsTheInterruptControlByte() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("^C").performClick() + + assertEquals(listOf("\u0003"), sent) + } + + @Test + fun shiftTabTap_sendsTheModeCycleSequence() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("⇧Tab").performClick() + + assertEquals(listOf("\u001b[Z"), sent) + } + + /** + * The bar is horizontally scrollable precisely so every key stays reachable on a narrow phone; the keys + * past the fold are the ones a layout regression would silently drop. Reaching one by its + * `contentDescription` (which is also its accessibility label) proves it is present and hittable. + */ + @Test + fun everyKeyIsReachable_includingTheOnesPastTheFold() { + val sent = mutableListOf() + setUpKeyBar(sent) + + // Off-screen keys must be reached the way a user reaches them — by scrolling the bar. A bare + // performClick() on a node outside the viewport fails, which is itself the proof that this key + // really does sit past the fold rather than being trivially present. + compose.onNodeWithContentDescription(SLASH_DESCRIPTION).performScrollTo().performClick() + + assertEquals(listOf("/"), sent) + } + + @Test + fun tapsAccumulateInOrder_soTwoFastTapsCannotSwap() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("↑").performClick() + compose.onNodeWithText("↓").performClick() + compose.onNodeWithText("⏎").performClick() + + assertEquals(listOf("\u001b[A", "\u001b[B", "\r"), sent) + } + + /** + * A key-bar tap must never route through a text field, or the soft keyboard would open. There is no + * public hook to observe "the IME was not asked to open", but there IS an observable proxy: the bar + * contains no text-input node at all, so nothing on it can request the IME. Combined with the byte + * assertions above (which prove the tap reached the wire), that pins the bypass. + */ + @Test + fun theBarHoldsNoTextInput_soATapCannotRaiseTheKeyboard() { + val sent = mutableListOf() + setUpKeyBar(sent) + + compose.onNodeWithText("Esc").assertIsDisplayed() + val editableNodes = compose + .onAllNodes(hasSetTextAction()) + .fetchSemanticsNodes(atLeastOneRootRequired = false) + + assertTrue("the key-bar must contain no editable text node", editableNodes.isEmpty()) + } + + private fun setUpKeyBar(sink: MutableList) { + compose.setContent { + WebTermTheme { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { + KeyBar(onSend = { bytes -> sink += bytes }) + } + } + } + } + + private companion object { + /** `KEYBAR_LAYOUT`'s accessibility label for the command-launcher key, which sits past the fold. */ + const val SLASH_DESCRIPTION = "Slash — command launcher" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/ReconnectBannerUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/ReconnectBannerUiTest.kt new file mode 100644 index 0000000..1394d31 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/ReconnectBannerUiTest.kt @@ -0,0 +1,134 @@ +package wang.yaojia.webterm.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.components.BannerModel +import wang.yaojia.webterm.session.ConnectionState +import wang.yaojia.webterm.session.Connection +import wang.yaojia.webterm.session.FailureReason +import wang.yaojia.webterm.components.ReconnectBanner +import wang.yaojia.webterm.designsystem.WebTermTheme +import kotlin.time.Duration.Companion.seconds + +/** + * **Plan §7 Compose UI test — the reconnect banner, and specifically its precedence rule.** + * + * The banner is the only place the app tells a walked-away user *why* their session went quiet, so the + * failure modes it must not have are: showing a retry spinner for something that can never succeed, and + * hiding a terminal state behind a transient one. The reduction itself is JVM-tested; what only a device can + * check is that the rendered surface honours it — the spinner really is absent, the affordance really is + * present and really fires, and the untrusted exit `reason` really renders inert. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class ReconnectBannerUiTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun hiddenRendersNothingAtAll() { + setUpBanner(BannerModel.Hidden) + + val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot()) + .fetchSemanticsNodes(atLeastOneRootRequired = false) + // A root always exists; what must not exist is any banner content under it. + assertTrue("Hidden must render no banner content", nodes.all { it.children.isEmpty() }) + } + + @Test + fun reconnectingShowsTheAttemptCountdownAndASpinner() { + setUpBanner(BannerModel.Reconnecting(attempt = 3, countdown = 4.seconds)) + + compose.onNodeWithText("连接断开,重连中(第 3 次,4 秒后重试)").assertIsDisplayed() + assertTrue("a retrying banner must show a spinner", hasSpinner()) + } + + /** + * The replay-too-large wall is NON-retryable: reconnecting would hit the same wall forever, so a + * spinner here would be a lie that never resolves. The copy has to be actionable and the only way out + * is a new session. + */ + @Test + fun aNonRetryableFailureShowsNoSpinnerAndOffersANewSession() { + var newSessions = 0 + setUpBanner(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE)) { newSessions += 1 } + + assertTrue("a non-retryable wall must NOT show a retry spinner", !hasSpinner()) + compose.onNodeWithText("新会话").performClick() + + assertEquals(1, newSessions) + } + + /** + * `exit(-1)` is a spawn failure, and its `reason` is server-supplied, i.e. untrusted — it must render as + * inert text inside the banner copy, never as a link or markup (plan §8). + */ + @Test + fun aSpawnFailureRendersTheServerReasonInertly() { + setUpBanner(BannerModel.Exited(code = -1, reason = UNTRUSTED_REASON)) + + compose.onNodeWithText("会话启动失败:$UNTRUSTED_REASON").assertIsDisplayed() + assertTrue("a terminal exit must not show a spinner", !hasSpinner()) + compose.onNodeWithText("开新会话").assertIsDisplayed() + } + + @Test + fun anOrdinaryExitShowsTheCodeAndTheNewSessionAffordance() { + var newSessions = 0 + setUpBanner(BannerModel.Exited(code = 130, reason = null)) { newSessions += 1 } + + compose.onNodeWithText("会话已结束(退出码 130)").assertIsDisplayed() + compose.onNodeWithText("开新会话").performClick() + + assertEquals(1, newSessions) + } + + /** + * **The precedence rule, rendered.** A `connecting` event arriving after the session already exited must + * NOT replace the terminal banner with a spinner — otherwise a walked-away user sees "connecting…" + * forever and never learns the session is over. The reduction is asserted and then the reduced model is + * rendered, so copy and rule are checked as one thing. + */ + @Test + fun aTerminalBannerIsStickyOverATransientOne() { + val exited = BannerModel.Exited(code = 1, reason = null) + val afterConnecting = wang.yaojia.webterm.components.bannerModel( + current = exited, + event = Connection(ConnectionState.Connecting), + ) + + assertEquals("a transient event must not override a terminal banner", exited, afterConnecting) + + setUpBanner(afterConnecting) + compose.onNodeWithText("会话已结束(退出码 1)").assertIsDisplayed() + assertTrue(!hasSpinner()) + } + + private fun hasSpinner(): Boolean = compose + .onAllNodes(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) + .fetchSemanticsNodes(atLeastOneRootRequired = false) + .isNotEmpty() + + private fun setUpBanner(model: BannerModel, onNewSession: () -> Unit = {}) { + compose.setContent { + WebTermTheme { ReconnectBanner(model = model, onNewSession = onNewSession) } + } + } + + private companion object { + /** Server-supplied and therefore untrusted; it must appear verbatim and inert. */ + const val UNTRUSTED_REASON = "spawn /bin/nope ENOENT" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/SessionListUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/SessionListUiTest.kt new file mode 100644 index 0000000..958c764 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/SessionListUiTest.kt @@ -0,0 +1,134 @@ +package wang.yaojia.webterm.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.api.models.LiveSessionInfo +import wang.yaojia.webterm.components.ContinueLastBanner +import wang.yaojia.webterm.components.ContinueLastModel +import wang.yaojia.webterm.components.SessionListRow +import wang.yaojia.webterm.components.continueLastModel +import wang.yaojia.webterm.designsystem.DisplayStatus +import wang.yaojia.webterm.designsystem.WebTermTheme +import wang.yaojia.webterm.viewmodels.SessionRow +import wang.yaojia.webterm.wire.ClaudeStatus +import java.util.UUID + +/** + * **Plan §7 Compose UI tests — session-list rows and the continue-last banner.** + * + * Two things only a device can check here. First, that a row renders host-supplied strings **inert**: the + * title arrives from an OSC escape the host (or something running on it) chose, so it must be plain + * [androidx.compose.material3.Text] with no linkify and no markdown (plan §8). Second, that "shown iff there + * is something to show" holds — a `null` model must render literally nothing, because a dead + * continue-affordance that navigates nowhere is worse than no affordance. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class SessionListUiTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun aRowRendersTheSanitizedTitleAndTheGeometry() { + compose.setContent { + WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = {}) } + } + + compose.onNodeWithText(TITLE).assertIsDisplayed() + // The geometry label uses U+00D7 MULTIPLICATION SIGN, not the letter x. + compose.onNodeWithText("161×50", substring = true).assertIsDisplayed() + } + + @Test + fun tappingARowOpensThatSession() { + var opened = 0 + compose.setContent { + WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = { opened += 1 }) } + } + + compose.onNodeWithText(TITLE).performClick() + + assertEquals(1, opened) + } + + /** + * A hostile OSC title must appear as characters, not as behaviour. `TitleSanitizer` strips bidi and + * zero-width runs upstream (JVM-tested); what this pins is that whatever survives is rendered verbatim + * and is not turned into a link by autolinking. + */ + @Test + fun anUntrustedLookingTitleIsRenderedAsPlainCharacters() { + compose.setContent { + WebTermTheme { SessionListRow(row = row(title = URL_LOOKING_TITLE), onOpen = {}) } + } + + compose.onNodeWithText(URL_LOOKING_TITLE).assertIsDisplayed() + } + + @Test + fun continueLastBannerIsAbsentWhenThereIsNoLastSession() { + assertEquals("no last session must reduce to no model", null, continueLastModel(null)) + + compose.setContent { + WebTermTheme { ContinueLastBanner(model = continueLastModel(null), onContinue = {}) } + } + + val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot()) + .fetchSemanticsNodes(atLeastOneRootRequired = false) + assertTrue("a null model must render nothing at all", nodes.all { it.children.isEmpty() }) + } + + @Test + fun continueLastBannerReOpensTheRememberedSession() { + val remembered = mutableListOf() + compose.setContent { + WebTermTheme { + ContinueLastBanner( + model = ContinueLastModel(LAST_SESSION_ID), + onContinue = { remembered += it }, + ) + } + } + + compose.onNodeWithText("继续上次会话").performClick() + + assertEquals( + "the banner must hand back the id it was rendered with, not a re-derived one", + listOf(LAST_SESSION_ID), + remembered, + ) + } + + private fun row(title: String) = SessionRow( + info = LiveSessionInfo( + id = UUID.fromString(LAST_SESSION_ID), + createdAt = 1_700_000_000_000L, + clientCount = 1, + status = ClaudeStatus.WORKING, + exited = false, + cwd = "/Users/dev/project", + title = title, + cols = 161, + rows = 50, + ), + displayStatus = DisplayStatus.Working, + title = title, + isUnread = true, + ) + + private companion object { + const val TITLE = "claude" + const val URL_LOOKING_TITLE = "https://example.com/not-a-link" + const val LAST_SESSION_ID = "a1b2c3d4-5678-4abc-9def-000000000000" + } +} diff --git a/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/PairAttachTypeApproveBenchmark.kt b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/PairAttachTypeApproveBenchmark.kt new file mode 100644 index 0000000..7f6a494 --- /dev/null +++ b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/PairAttachTypeApproveBenchmark.kt @@ -0,0 +1,156 @@ +package wang.yaojia.webterm.macrobenchmark + +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * **A35 — the one happy-path journey: pair → attach → type → approve.** + * + * The whole product in a single measured trace, driven out of process against `:app`'s minified `benchmark` + * variant. [FrameTimingMetric] is the point: the terminal is a text renderer fed by a socket, so what a user + * feels is frame duration while output streams and while the key-bar is tapped — not the wall clock of any + * single step. [StartupTimingMetric] rides along so a regression can be attributed to launch versus journey. + * + * ### It needs a real host, and it SKIPS when there isn't one + * Pair, attach, type and approve are all *server* interactions. Faking them would measure a mock, so + * [WebTermTarget.requireHost] raises a JUnit assumption failure — reported as skipped, never as a pass — + * when `-e webtermHost` is absent. A benchmark that "passed" while stuck on the pairing screen would publish + * an impressively fast, entirely meaningless number, which is worse than no number. + * + * ### It also needs a gate to approve, which cannot be manufactured from here + * The approve step is only reachable when the session is actually holding a permission gate (Claude Code + * asking for a tool). This benchmark therefore *looks* for the approve affordance and, when it is absent, + * completes the journey without it and says so — rather than typing something that might approve a real + * action on a real machine. The full gate leg belongs to a run against a live Claude session, and stays on + * the device-QA checklist under A35. + * + * Run: + * ``` + * ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.webtermHost=http://10.0.2.2:3000 + * ``` + */ +@RunWith(AndroidJUnit4::class) +class PairAttachTypeApproveBenchmark { + + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun pairAttachTypeApprove() { + val host = WebTermTarget.requireHost() + + benchmarkRule.measureRepeated( + packageName = WebTermTarget.PACKAGE_NAME, + metrics = listOf(StartupTimingMetric(), FrameTimingMetric()), + iterations = WebTermTarget.JOURNEY_ITERATIONS, + // COLD each iteration: pairing state persists in DataStore, so a warm run would skip the pairing + // leg entirely and silently measure a different (shorter) journey. + startupMode = StartupMode.COLD, + setupBlock = { pressHome() }, + ) { + startActivityAndWait() + WebTermTarget.awaitFirstContent(device) + + pairIfNeeded(device, host) + openASession(device) + typeIntoTheTerminal(device) + approveIfAGateIsHeld(device) + } + } + + /** + * The pairing leg. On a device that is already paired the app cold-starts past this screen (A29's + * `ColdStartPolicy`), so the whole leg is conditional — which is also why the journey runs COLD. + */ + private fun pairIfNeeded(device: UiDevice, host: String) { + val onPairing = device.wait( + Until.hasObject(By.textContains(WebTermTarget.PAIRING_TITLE)), + WebTermTarget.UI_TIMEOUT_MS, + ) == true + if (!onPairing) return + + // The segmented control defaults to manual entry, but selecting it explicitly keeps the journey + // deterministic if that default ever changes. + device.findObject(By.textContains(WebTermTarget.MANUAL_ENTRY))?.click() + + val field = device.wait( + Until.findObject(By.textContains(WebTermTarget.HOST_FIELD_HINT)), + WebTermTarget.UI_TIMEOUT_MS, + ) ?: device.findObject(By.clazz("android.widget.EditText")) + checkNotNull(field) { "the pairing screen showed no host-address field" } + field.click() + field.text = host + + val next = device.wait( + Until.findObject(By.textContains(WebTermTarget.NEXT).enabled(true)), + WebTermTarget.UI_TIMEOUT_MS, + ) + checkNotNull(next) { "the 下一步 button never became enabled for $host — the probe failed" } + next.click() + } + + /** Open a session: the continue-last affordance if there is one, else the first row in the list. */ + private fun openASession(device: UiDevice) { + val continueLast = device.wait( + Until.findObject(By.textContains(WebTermTarget.CONTINUE_LAST)), + WebTermTarget.UI_TIMEOUT_MS, + ) + if (continueLast != null) { + continueLast.click() + } else { + // Any row works; the journey is about attach + render, not about which session. + val row = device.wait( + Until.findObject(By.pkg(WebTermTarget.PACKAGE_NAME).clickable(true)), + WebTermTarget.UI_TIMEOUT_MS, + ) + checkNotNull(row) { "no session was openable — is the host running any sessions?" } + row.click() + } + // Attach + ring-buffer replay: the frames this produces are the ones FrameTimingMetric is here for. + device.waitForIdle(WebTermTarget.ATTACH_TIMEOUT_MS) + } + + /** + * Type through the key-bar, not the IME. That is the real mobile input path (the bar bypasses the IME + * precisely so the keyboard never pops), and it avoids making the measurement depend on which IME the + * device happens to ship. + */ + private fun typeIntoTheTerminal(device: UiDevice) { + val enter = device.wait(Until.findObject(By.desc(ENTER_KEY_DESCRIPTION)), WebTermTarget.UI_TIMEOUT_MS) + if (enter != null) { + enter.click() + device.waitForIdle() + return + } + // Wide screens hide the key-bar (web parity, >768dp) — fall back to the terminal surface itself. + device.click(device.displayWidth / 2, device.displayHeight / 2) + device.waitForIdle() + } + + /** + * Approve — only if a gate is genuinely held. Absent a gate the affordance is not on screen and this is + * a no-op: the alternative (synthesising a tap where the button would be) risks approving a real tool + * call on a real machine, which a benchmark has no business doing. + */ + private fun approveIfAGateIsHeld(device: UiDevice) { + device.findObject(By.text(WebTermTarget.APPROVE))?.let { approve -> + approve.click() + device.waitForIdle() + } + } + + private companion object { + /** `KEYBAR_LAYOUT`'s accessibility label for the Enter key. */ + const val ENTER_KEY_DESCRIPTION = "Enter — confirm" + } +} diff --git a/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/StartupBenchmark.kt b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/StartupBenchmark.kt new file mode 100644 index 0000000..5b2d50c --- /dev/null +++ b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/StartupBenchmark.kt @@ -0,0 +1,67 @@ +package wang.yaojia.webterm.macrobenchmark + +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * **A35 — cold- and warm-start timing for the `benchmark` variant of `:app`.** + * + * Measured out of process by UiAutomator against a non-debuggable, R8'd, resource-shrunk APK, because those + * are the only numbers that mean anything: a debug build's timings are dominated by the absence of R8 and by + * debuggable-mode JIT policy, and an in-process test perturbs the thing it measures. + * + * ### What counts as a result + * Real hardware only. The `webterm` AVD is a **software-GPU emulator**: its startup figures (≈1.1 s cold, + * recorded in `DEVICE_QA_CHECKLIST.md`) are proof that the app starts cleanly under R8, and are NOT a + * baseline. Quoting an emulator number as a performance baseline is the specific mistake this comment + * exists to prevent. Macrobenchmark itself agrees: it refuses to report on an emulator unless + * `androidx.benchmark.suppressErrors` is set, so a run there is explicitly a smoke test. + * + * Run: + * ``` + * ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest + * ``` + */ +@RunWith(AndroidJUnit4::class) +class StartupBenchmark { + + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + /** + * COLD start — process creation included. This is the number that matters for the product's core use + * case: picking the phone up hours later to check on a walked-away Claude session. + */ + @Test + fun coldStartup() = measureStartup(StartupMode.COLD) + + /** + * WARM start — the process survived, the Activity did not. Kept because it isolates Activity/Compose + * setup from process creation, so a regression can be attributed rather than merely noticed. + */ + @Test + fun warmStartup() = measureStartup(StartupMode.WARM) + + private fun measureStartup(mode: StartupMode) = benchmarkRule.measureRepeated( + packageName = WebTermTarget.PACKAGE_NAME, + metrics = listOf(StartupTimingMetric()), + iterations = WebTermTarget.STARTUP_ITERATIONS, + startupMode = mode, + setupBlock = { + // COLD needs the process gone; pressHome also clears any leftover Activity from a prior + // iteration so warm runs measure Activity creation rather than a resume. + pressHome() + }, + ) { + startActivityAndWait() + // Waiting for the first real frame is what makes the measurement about the app being USABLE rather + // than about the window animation. Whichever route cold start lands on (pairing screen with no host, + // session list with one), something from the app's own content must be on screen. + WebTermTarget.awaitFirstContent(device) + } +} diff --git a/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/WebTermTarget.kt b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/WebTermTarget.kt new file mode 100644 index 0000000..0838c33 --- /dev/null +++ b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/WebTermTarget.kt @@ -0,0 +1,88 @@ +package wang.yaojia.webterm.macrobenchmark + +import android.os.Bundle +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.Assume + +/** + * What the out-of-process harness knows about `:app` — deliberately almost nothing. + * + * `:macrobenchmark` is a separate APK driving a separate process, so it cannot (and must not) link against + * `:app`'s classes: that is what keeps the measured process the shipping one. Everything it needs is + * therefore either a package name, a piece of on-screen text, or a run-time argument. + * + * The UI strings below are duplicated from `:app`'s Compose sources rather than shared, which is a real cost + * (a copy-rename will not fail the compile, it will fail the benchmark at run time with "no node matched"). + * The alternative — a `project(":app")` dependency — would break the out-of-process property that makes the + * numbers valid, so the duplication is the right trade. It is kept small on purpose. + */ +internal object WebTermTarget { + + /** `:app`'s `applicationId`. The debug and benchmark variants share it (no suffix), by design (A32). */ + const val PACKAGE_NAME: String = "wang.yaojia.webterm" + + const val STARTUP_ITERATIONS: Int = 5 + const val JOURNEY_ITERATIONS: Int = 3 + + /** How long to wait for a Compose surface to appear before calling it a failure. */ + const val UI_TIMEOUT_MS: Long = 10_000L + + /** Long enough for a real attach + ring-buffer replay over a LAN. */ + const val ATTACH_TIMEOUT_MS: Long = 20_000L + + // ── On-screen text (mirrors :app; see the class doc for why it is duplicated) ──────────────── + const val PAIRING_TITLE: String = "配对主机" + const val HOST_FIELD_HINT: String = "主机地址(http(s)://…)" + const val NEXT: String = "下一步" + const val MANUAL_ENTRY: String = "手动输入" + const val APPROVE: String = "批准" + const val CONTINUE_LAST: String = "继续上次会话" + + /** Instrumentation argument naming a reachable WebTerm host, mirroring the A34 suite's own. */ + const val ARG_HOST: String = "webtermHost" + + private val arguments: Bundle get() = InstrumentationRegistry.getArguments() + + fun argument(name: String): String? = arguments.getString(name)?.takeIf { it.isNotBlank() } + + /** + * Wait until the app has painted something of its OWN — not just a window. Cold start with no paired + * host lands on the pairing screen; with a host it lands on the session list, which shows the + * continue-last affordance. Accepting either keeps the startup measurement valid on a device in any + * state, while still failing loudly on a blank or crashed first frame. + */ + fun awaitFirstContent(device: UiDevice) { + val appeared = device.wait( + Until.hasObject(By.textContains(PAIRING_TITLE)), + UI_TIMEOUT_MS, + ) == true || + device.wait(Until.hasObject(By.textContains(CONTINUE_LAST)), UI_TIMEOUT_MS) == true || + device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), UI_TIMEOUT_MS) != null + check(appeared) { "the app produced no first frame within $UI_TIMEOUT_MS ms" } + } + + /** + * Skip — never silently pass — when the journey benchmark has no host to talk to. A pair → attach → + * type → approve journey is meaningless against nothing, and a benchmark that "succeeded" by never + * leaving the pairing screen would publish a fast, wrong number. + */ + fun requireHost(): String { + val host = argument(ARG_HOST) + Assume.assumeTrue( + """ + |SKIPPED — the pair → attach → type → approve journey needs a real WebTerm host, and none was + |supplied. Start this repo's server (allowing the origin the device will send) and pass its + |address: + | ALLOWED_ORIGINS=http://:3000 npm start + | ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \ + | -Pandroid.testInstrumentationRunnerArguments.$ARG_HOST=http://:3000 + |On an emulator, is 10.0.2.2 (the platform alias for the host's loopback). + """.trimMargin(), + host != null, + ) + return host!! + } +} From 390dd1120223a41cd2f8c043fc8dd95bd8d75cdc Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 15:37:09 +0200 Subject: [PATCH 09/10] feat(android): close the audit's remaining parity gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven items the earlier repair waves deliberately left alone, so that the blocker fixes could land with a working safety net first. CERTIFICATE LIFECYCLE (the audit's only "high"). iOS ships a CertificateRotationScheduler; Android had no counterpart, so a device certificate simply expired and needed a manual re-enroll. The decision is a pure total function over five answers, with RE_ENROLL_REQUIRED checked first so it outranks any backoff. Renewal is single-flight — a second trigger coalesces, and a cancelled leader releases the slot so cancellation cannot wedge rotation for the process lifetime. Failure leaves the prior identity fully live (the existing atomic ping-pong flip already guaranteed that) and is published rather than swallowed. Two real defects surfaced while building it: renew() decoded its 201 with EnrollResponseDto, whose deviceId is required — but /device/:id/renew returns only {cert, caChain, notAfter}; only /device/enroll echoes deviceId. Every silent renewal would have thrown MalformedResponse. Fixed with a RenewResponseDto plus the deviceId from the path segment we addressed. And because no renew 201 carries renewAfter, a client that persisted it would rotate exactly once and then report not-due until the cert died. That is why renewAfter is re-derived from the live leaf as notBefore + 2/3·lifetime — the control plane's own formula. This is a latent iOS defect that Android now avoids rather than inherits. /device/:id/recover was confirmed real (control-plane/src/api/renew.ts:443, contract pinned by that suite's CP6e) and is now wired. It goes over a separate NON-mTLS client that throws rather than falling back, because an expired client certificate cannot authenticate its own recovery — the deadlock a previous session already hit and recorded. Asked explicitly about step-up: rotation is unaffected. stepUp appears nowhere in control-plane/src; it lives only on the relay's WebSocket upgrade. On a step-up host the SESSION is denied, not the rotation, so the certificate still stays alive and that principal gap is orthogonal. FOLLOW-UP QUEUE (W2). The queue frame decoded but the three routes managing it were missing, so Android could see "N queued" and not queue anything. One outcome union covers both guarded writes and distinguishes full / too-large / disabled / session-gone / rate-limited, because they need different copy. 429 is surfaced and never auto-retried — POST and DELETE share one 20/min bucket. Queued text is raw shell input and travels verbatim, proven with control characters and CJK. UNREAD WATERMARK now persists, so the unread state survives process death — which is exactly when it matters, since the product premise is walking away and coming back. It stores a plain map rather than an UnreadLedger: the reducer lives in :session-core, which :host-registry may not depend on, so the logic is not restated anywhere. COPY-OUT. Text selection was a recorded feature gap: the stock ActionMode's own Copy handler dereferences the absent TerminalSession, so making it reachable puts an NPE on the button — worse than no selection. The refusal was re-verified from the bytecode, and the same disassembly showed the way out: TerminalBuffer.getSelectedText touches only mLines/TerminalRow, no session and no view. A first-party selection layer now builds on that, with a pure row-major model so a backwards drag normalises correctly. Terminal content reaches the clipboard only on an explicit user copy; OSC 52 stays declined. HOST REMOVAL. PushRegistrar.unregisterHost had zero call sites, so a removed host kept receiving pushes. Removal is now confirm-gated and cleans everything keyed by host id — a half-removed host is worse than none. /config/ui is finally honoured. It was implemented and never called, so allowAutoMode was ignored and the plan gate offered "approve + auto" even where the operator had disabled it. It fails CLOSED: an unfetchable config treats auto as disabled, because the permissive default is the dangerous one. Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 1150 JVM tests, 0 failures (903 -> 1150). Instrumented suite re-run on emulator-5554 against live servers: 67 tests, 0 failures, 0 skipped — no device regression. --- .../api/enroll/DeviceEnrollmentClient.kt | 68 +++- .../webterm/api/enroll/EnrollmentModels.kt | 32 ++ .../webterm/api/enroll/RotationPolicy.kt | 150 ++++++++ .../webterm/api/models/LiveSessionInfo.kt | 5 + .../yaojia/webterm/api/models/PromptQueue.kt | 77 ++++ .../yaojia/webterm/api/models/Serializers.kt | 21 ++ .../yaojia/webterm/api/routes/ApiClient.kt | 62 ++++ .../webterm/api/routes/ApiClientError.kt | 5 + .../yaojia/webterm/api/routes/ApiRoute.kt | 3 + .../yaojia/webterm/api/routes/Endpoints.kt | 48 ++- .../api/enroll/DeviceEnrollmentClientTest.kt | 131 +++++++ .../webterm/api/enroll/RotationPolicyTest.kt | 228 ++++++++++++ .../webterm/api/models/PromptQueueTest.kt | 74 ++++ .../webterm/api/routes/ApiClientQueueTest.kt | 141 ++++++++ .../webterm/api/routes/QueueRouteShapeTest.kt | 162 +++++++++ .../yaojia/webterm/components/QueuePanel.kt | 271 ++++++++++++++ .../webterm/components/SessionListRow.kt | 13 +- .../java/wang/yaojia/webterm/di/PushModule.kt | 50 +++ .../wang/yaojia/webterm/di/StorageModule.kt | 24 +- .../java/wang/yaojia/webterm/di/TlsModule.kt | 33 ++ .../webterm/screens/ClientCertScreen.kt | 58 +++ .../webterm/screens/SessionListScreen.kt | 193 +++++++++- .../yaojia/webterm/screens/TerminalScreen.kt | 182 +++++++++- .../webterm/viewmodels/GateViewModel.kt | 58 ++- .../webterm/viewmodels/QueueViewModel.kt | 269 ++++++++++++++ .../viewmodels/SessionListViewModel.kt | 167 ++++++++- .../yaojia/webterm/wiring/AppEnvironment.kt | 301 ++++++++++++++++ .../wiring/CertificateRotationScheduler.kt | 328 +++++++++++++++++ .../webterm/viewmodels/GateAutoModeTest.kt | 184 ++++++++++ .../webterm/viewmodels/GateViewModelTest.kt | 3 + .../webterm/viewmodels/QueueViewModelTest.kt | 245 +++++++++++++ .../viewmodels/SessionListPersistenceTest.kt | 293 ++++++++++++++++ .../viewmodels/SurfaceCopyMappingTest.kt | 106 ++++++ .../CertificateRotationSchedulerTest.kt | 331 ++++++++++++++++++ .../wiring/CertificateRotationTriggerTest.kt | 89 +++++ .../yaojia/webterm/wiring/HostRemoverTest.kt | 192 ++++++++++ .../yaojia/webterm/wiring/UiConfigGateTest.kt | 85 +++++ .../webterm/tlsandroid/DeviceEnroller.kt | 51 +++ .../webterm/tlsandroid/DeviceRotationStore.kt | 60 ++++ .../tlsandroid/EnrollmentRecordStore.kt | 32 +- .../webterm/tlsandroid/DeviceEnrollerTest.kt | 224 +++++++++--- .../tlsandroid/DeviceRotationStoreTest.kt | 166 +++++++++ .../tlsandroid/EnrollmentRecordCodecTest.kt | 53 +++ .../tlsandroid/RotationTestFixtures.kt | 78 +++++ .../DataStoreSessionWatermarkStoreTest.kt | 188 ++++++++++ .../DataStoreSessionWatermarkStore.kt | 65 ++++ .../InMemorySessionWatermarkStore.kt | 40 +++ .../hostregistry/SessionWatermarkCodec.kt | 33 ++ .../hostregistry/SessionWatermarkStore.kt | 151 ++++++++ .../InMemorySessionWatermarkStoreTest.kt | 77 ++++ .../hostregistry/SessionWatermarkCodecTest.kt | 60 ++++ .../SessionWatermarkTransformsTest.kt | 150 ++++++++ .../terminalview/RemoteTerminalHostView.kt | 235 ++++++++++++- .../terminalview/RemoteTerminalView.kt | 47 ++- .../webterm/terminalview/TerminalClipboard.kt | 76 ++++ .../webterm/terminalview/TerminalInputSink.kt | 16 +- .../TerminalSelectionActionMode.kt | 76 ++++ .../terminalview/TerminalSelectionBuffer.kt | 145 ++++++++ .../TerminalSelectionController.kt | 124 +++++++ .../terminalview/TerminalSelectionGeometry.kt | 80 +++++ .../terminalview/TerminalSelectionModel.kt | 169 +++++++++ .../terminalview/TerminalSelectionPainter.kt | 44 +++ .../terminalview/WebTermTerminalViewClient.kt | 54 ++- .../RemoteTerminalSelectionTest.kt | 319 +++++++++++++++++ .../terminalview/StockTerminalViewFixture.kt | 5 + .../TerminalSelectionBufferTest.kt | 186 ++++++++++ .../TerminalSelectionControllerTest.kt | 249 +++++++++++++ .../TerminalSelectionGeometryTest.kt | 117 +++++++ .../TerminalSelectionModelTest.kt | 158 +++++++++ .../WebTermTerminalViewClientTest.kt | 27 ++ 70 files changed, 8096 insertions(+), 141 deletions(-) create mode 100644 android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicy.kt create mode 100644 android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PromptQueue.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicyTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PromptQueueTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientQueueTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/QueueRouteShapeTest.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/QueuePanel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/QueueViewModel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/CertificateRotationScheduler.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateAutoModeTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/QueueViewModelTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListPersistenceTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/SurfaceCopyMappingTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationSchedulerTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationTriggerTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/UiConfigGateTest.kt create mode 100644 android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStore.kt create mode 100644 android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStoreTest.kt create mode 100644 android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordCodecTest.kt create mode 100644 android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/RotationTestFixtures.kt create mode 100644 android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStoreTest.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStore.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStore.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodec.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkStore.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStoreTest.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodecTest.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkTransformsTest.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalClipboard.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionActionMode.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBuffer.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionController.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometry.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModel.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionPainter.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSelectionTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBufferTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionControllerTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometryTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModelTest.kt diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt index 00ea5eb..f5de8e2 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClient.kt @@ -15,8 +15,11 @@ import wang.yaojia.webterm.wire.HttpTransport * `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain, * deviceName, attestation? }` → 201 `{ deviceId, cert, caChain, * notBefore, notAfter, renewAfter }` - * `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The - * server schema is `.strict()`; NO keyAlg/subdomain/deviceName. + * `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 `{ cert, caChain, + * notAfter }`. The server schema is `.strict()`; NO + * keyAlg/subdomain/deviceName. + * `POST /device/:id/recover` [NO client cert — plain HTTPS] `{ cert, csr }` → 201 `{ cert, + * caChain, notAfter }`. The EXPIRED leaf's escape hatch. * * Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is * sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs @@ -29,8 +32,12 @@ public class DeviceEnrollmentClient( baseUrl: String, private val http: HttpTransport, ) { - /** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */ - private val base: String = baseUrl.trim().trimEnd('/') + /** + * Base control-plane URL with any trailing slash removed, so `baseUrl + path` is well-formed. + * Readable so a rotation driver can persist WHICH control plane a device enrolled with and renew + * against that same one (a URL is not a credential). + */ + public val baseUrl: String = baseUrl.trim().trimEnd('/') /** * One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is @@ -98,7 +105,38 @@ public class DeviceEnrollmentClient( ) val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew" val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken)) - return toResult(decodeOn201(response, EnrollResponseDto.serializer())) + // The renew 201 is `{ cert, caChain, notAfter }` — it does NOT echo deviceId, so the id we + // addressed the request to is the authoritative one. + return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId) + } + + /** + * Recover an EXPIRED leaf: `POST /device/:id/recover` carrying the lapsed [expiredCertDer] in the + * BODY plus a fresh [csrDer] over the SAME hardware key. + * + * **This call must NOT ride an mTLS transport.** `/renew` is authenticated by the very leaf it + * renews, so a lapsed leaf cannot renew itself — and no TLS terminator will even forward an expired + * client certificate (nginx answers a bare 400 under `ssl_verify_client optional`; `optional_no_ca` + * tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). The caller therefore supplies a + * PLAIN [HttpTransport] with no client identity; possession is proven by the CSR self-signature, + * which the control plane checks together with `CSR key == registered key`. + * + * No bearer is accepted on this path at all — the body is the whole credential story. Everything + * else the server enforces is unchanged: real X.509 path validation to the device-CA, SPIFFE parse, + * `notBefore` (never graced), `:id` must match the cert, and revocation still applies. Only + * `notAfter` is graced (30 days), so a leaf lapsed beyond that answers 401. + */ + public suspend fun recover(deviceId: String, expiredCertDer: ByteArray, csrDer: ByteArray): EnrollmentResult { + if (deviceId.isEmpty() || expiredCertDer.isEmpty() || csrDer.isEmpty()) { + throw DeviceEnrollmentError.InvalidRequest + } + val body = EnrollJson.encodeToString( + RecoverRequestBody.serializer(), + RecoverRequestBody(cert = base64(expiredCertDer), csr = base64(csrDer)), + ) + val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/recover" + val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearer = null)) + return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId) } // ── Request/response plumbing ──────────────────────────────────────────────────────────── @@ -114,7 +152,7 @@ public class DeviceEnrollmentClient( // Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty // string must never emit a bare "Bearer " header. if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer" - return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody) + return HttpRequest(method = method, url = baseUrl + path, headers = headers, body = jsonBody) } /** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error` @@ -127,6 +165,24 @@ public class DeviceEnrollmentClient( .getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse } + /** + * Map a re-issuance (renew / recover) 201, whose body carries no `deviceId`, using the [deviceId] + * the request was addressed to. `renewAfter` is normally absent here — the rotation policy + * re-derives it from the leaf's own validity window (`RotationPolicy.renewAfterFor`). + */ + private fun toReissueResult(dto: RenewResponseDto, deviceId: String): EnrollmentResult = + EnrollmentResult( + deviceId = deviceId, + certificate = decodeDerOrThrow(dto.cert), + caChain = dto.caChain.map { decodeDerOrThrow(it) }, + notBefore = parseInstantOrNull(dto.notBefore), + notAfter = parseInstantOrNull(dto.notAfter), + renewAfter = parseInstantOrNull(dto.renewAfter), + ) + + private fun decodeDerOrThrow(base64Der: String): ByteArray = + decodeBase64OrNull(base64Der) ?: throw DeviceEnrollmentError.MalformedResponse + private fun toResult(dto: EnrollResponseDto): EnrollmentResult { val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse val chain = dto.caChain.map { entry -> diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt index 27148a8..6f1afc3 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/EnrollmentModels.kt @@ -97,6 +97,19 @@ internal data class EnrollRequestBody( @Serializable internal data class RenewRequestBody(val csr: String) +/** + * The `/device/:id/recover` request body — the EXPIRED leaf plus a fresh CSR over the same key. + * + * The lapsed certificate travels in the BODY (not as an mTLS client cert) because no TLS terminator + * forwards an expired client certificate: nginx answers a bare 400 under `ssl_verify_client optional`, + * and `optional_no_ca` tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`. Nothing is + * lost: the CSR is self-signed by the same private key and the control-plane signer enforces CSR + * proof-of-possession plus `CSR key == registered key`, so possession is proven exactly as the + * handshake used to prove it. The server schema is `.strict()` on these two keys. + */ +@Serializable +internal data class RecoverRequestBody(val cert: String, val csr: String) + @Serializable internal data class LoginResponseDto( val enrollToken: String, @@ -114,6 +127,25 @@ internal data class EnrollResponseDto( val renewAfter: String? = null, ) +/** + * The `/device/:id/renew` and `/device/:id/recover` 201 body — `{ cert, caChain, notAfter }` ONLY. + * + * Deliberately NOT [EnrollResponseDto]: the re-issuance routes do not echo `deviceId` (nor + * `notBefore`/`renewAfter`) — only `/device/enroll` does (`control-plane/src/api/renew.ts` vs + * `device-enroll.ts`). Decoding a renew 201 against the enroll DTO, whose `deviceId` is required, + * failed EVERY silent rotation with `MalformedResponse`. The device id is supplied by the caller (it + * is the path segment the request was addressed to, which is the authoritative value anyway), and the + * absent `renewAfter` is re-derived from the leaf by `RotationPolicy.renewAfterFor`. + */ +@Serializable +internal data class RenewResponseDto( + val cert: String, + val caChain: List = emptyList(), + val notBefore: String? = null, + val notAfter: String? = null, + val renewAfter: String? = null, +) + @Serializable internal data class ErrorDto(val error: String? = null) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicy.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicy.kt new file mode 100644 index 0000000..3168b13 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicy.kt @@ -0,0 +1,150 @@ +package wang.yaojia.webterm.api.enroll + +import java.time.Duration +import java.time.Instant + +/** + * Rotation timing of the installed device identity — the input to [RotationPolicy]. Android analogue + * of iOS `DeviceRenewalState`. + * + * [notAfter] is the leaf's HARD expiry (past it the TLS stack rejects the cert outright) and + * [renewAfter] is the advisory "renew from the same key now" instant. Only non-secret timing plus the + * non-secret [deviceId] (which is a URL path segment) surface here: the private key never leaves + * AndroidKeyStore and the cert bytes are never carried through this type, so it is safe to log. + */ +public data class DeviceRenewalState( + /** The enrolled device id — the `:id` in `POST /device/:id/renew` and `/device/:id/recover`. */ + val deviceId: String, + val notAfter: Instant?, + val renewAfter: Instant?, +) { + /** + * Is the leaf due for renewal as of [now]? A missing [renewAfter] NEVER triggers (fail-safe — the + * TLS stack is the real gate; the scheduler only pre-empts expiry). Mirrors + * [EnrollmentResult.isRenewalDue] and iOS `DeviceRenewalState.isRenewalDue`. + */ + public fun isRenewalDue(now: Instant): Boolean { + val due = renewAfter ?: return false + return !now.isBefore(due) // now >= renewAfter + } +} + +/** What a scheduler must do for one rotation pass — the total answer of [RotationPolicy.decide]. */ +public enum class RotationDecision { + /** Nothing to do: the renew window has not opened (the cheap, common case). */ + NOT_DUE, + + /** An attempt IS due but the previous one failed too recently — wait, do not hammer. */ + BACKING_OFF, + + /** Still valid: re-CSR from the same key and renew over mTLS (`POST /device/:id/renew`). */ + RENEW, + + /** + * EXPIRED but inside the recovery grace: the leaf can no longer authenticate its own re-issuance, + * so recover over PLAIN HTTPS with the lapsed cert in the body (`POST /device/:id/recover`). + */ + RECOVER, + + /** TERMINAL — expired past the grace window. No request can succeed; only a fresh enroll can. */ + RE_ENROLL_REQUIRED, +} + +/** + * The pure, JVM-testable rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`, + * `TerminalGridMath`). Given "now", the leaf's timing and the last failed attempt it answers + * renew / recover / wait / re-enroll — no clock, no keystore, no network, so every branch is a unit + * test rather than a device observation. + * + * Ported from iOS `CertificateRotationScheduler`'s timing rules, plus the two branches the phone + * track needs and iOS lacks (it can only renew, so an iOS device whose leaf lapses is stranded): + * - [RotationDecision.RECOVER] — the `/device/:id/recover` escape hatch, because no TLS terminator + * forwards an expired client certificate, so a lapsed leaf can never authenticate its own renewal. + * - [RotationDecision.RE_ENROLL_REQUIRED] — reported ONCE as terminal instead of retrying forever + * (the host-track agent logged the same failure 6380 times before this rule existed). + */ +public object RotationPolicy { + /** + * The control plane issues `renewAfter = notBefore + 2/3 · lifetime` + * (`control-plane/src/api/device-enroll.ts` `DEFAULT_RENEW_FRACTION`). The client mirrors the + * fraction as exact integer duration math so [renewAfterFor] lands on the same instant. + */ + private const val RENEW_FRACTION_NUMERATOR: Long = 2L + private const val RENEW_FRACTION_DENOMINATOR: Long = 3L + + /** + * How long after `notAfter` a lapsed leaf may still be swapped via `/device/:id/recover` + * (30 days) — the same window the control plane's `DEFAULT_EXPIRED_RENEW_GRACE_MS` allows. Asking + * outside it is pointless: the server answers 401. + */ + public val DEFAULT_EXPIRED_RECOVERY_GRACE: Duration = Duration.ofDays(30) + + /** + * Minimum gap after a FAILED attempt before another is made. A pass runs on every app + * foreground, and the control plane rate-limits renewals to 30/hour per identity, so an + * un-throttled retry converts one transient failure into a 429 storm. + */ + public val DEFAULT_FAILURE_BACKOFF: Duration = Duration.ofMinutes(15) + + /** + * Derive the advisory renew instant from the leaf's own validity window. + * + * This derivation is LOAD-BEARING, not a convenience: `POST /device/:id/renew` and + * `POST /device/:id/recover` answer `{cert, caChain, notAfter}` only — neither returns + * `renewAfter` (only `/device/enroll` does). A client that persisted the enroll-time value and + * never recomputed it would rotate exactly once and then report "not due" until the cert died. + * + * Returns null when either bound is unknown or the window is not a lifetime (notAfter <= + * notBefore) — fail-safe, because [decide] treats a null [DeviceRenewalState.renewAfter] as + * never-due rather than inventing a past instant that would renew-storm. + */ + public fun renewAfterFor(notBefore: Instant?, notAfter: Instant?): Instant? { + if (notBefore == null || notAfter == null) return null + if (!notBefore.isBefore(notAfter)) return null + val lifetime = Duration.between(notBefore, notAfter) + return notBefore.plus( + lifetime.multipliedBy(RENEW_FRACTION_NUMERATOR).dividedBy(RENEW_FRACTION_DENOMINATOR), + ) + } + + /** + * The whole decision table, in priority order: + * 1. expired past [expiredRecoveryGrace] → [RotationDecision.RE_ENROLL_REQUIRED] (terminal; it + * outranks the backoff because no amount of waiting can help), + * 2. otherwise pick the desired action — expired → RECOVER, [DeviceRenewalState.isRenewalDue] → + * RENEW, else NOT_DUE, + * 3. an idle leaf stays [RotationDecision.NOT_DUE] (there is no attempt to throttle), + * 4. a desired action within [failureBackoff] of [lastFailureAt] → [RotationDecision.BACKING_OFF]. + * + * @param lastFailureAt when the last attempt FAILED (null = none, or the last one succeeded). + */ + public fun decide( + state: DeviceRenewalState, + now: Instant, + lastFailureAt: Instant? = null, + expiredRecoveryGrace: Duration = DEFAULT_EXPIRED_RECOVERY_GRACE, + failureBackoff: Duration = DEFAULT_FAILURE_BACKOFF, + ): RotationDecision { + require(!expiredRecoveryGrace.isNegative) { "expiredRecoveryGrace must not be negative" } + require(!failureBackoff.isNegative) { "failureBackoff must not be negative" } + + val notAfter = state.notAfter + // Terminal first: past `notAfter + grace` nothing can succeed. A non-negative grace makes this + // strictly stronger than "expired", so it needs no separate expiry guard. + if (notAfter != null && now.isAfter(notAfter.plus(expiredRecoveryGrace))) { + return RotationDecision.RE_ENROLL_REQUIRED + } + val isExpired = notAfter != null && now.isAfter(notAfter) + + val desired = when { + isExpired -> RotationDecision.RECOVER + state.isRenewalDue(now) -> RotationDecision.RENEW + else -> RotationDecision.NOT_DUE + } + if (desired == RotationDecision.NOT_DUE) return RotationDecision.NOT_DUE + if (lastFailureAt != null && now.isBefore(lastFailureAt.plus(failureBackoff))) { + return RotationDecision.BACKING_OFF + } + return desired + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt index f25e0e6..ff06bb4 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt @@ -42,4 +42,9 @@ public data class LiveSessionInfo( /** Server ms timestamp of the last PTY output (== createdAt until first output). Additive * optional field — pre-P1 servers omit it; `null` means "no unread data source". */ val lastOutputAt: Long? = null, + /** W2 pending prompt-queue depth (`src/types.ts` `queueLength?`) — what the "N queued" badge + * renders. Additive OPTIONAL: `null` on a pre-W2 server (queue unsupported / unknown), `0` when + * the queue is empty; the badge shows only for a positive value. Decoded tolerantly + * ([LossyIntSerializer]) so a garbled value can never hide a running session. */ + @Serializable(with = LossyIntSerializer::class) val queueLength: Int? = null, ) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PromptQueue.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PromptQueue.kt new file mode 100644 index 0000000..f689994 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PromptQueue.kt @@ -0,0 +1,77 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.Serializable + +/** + * W2 prompt queue — the server-side FIFO of follow-up prompts injected into a session's PTY the next + * time Claude goes idle (`src/server.ts` ~605/644/654, `SessionManager.enqueueFollowup`/`clearQueue`). + * + * A queued entry is **raw keyboard input for a shell** (invariant #9 / byte-shuttle): it is stored and + * later written to the PTY verbatim. Nothing on this path may trim, escape, re-encode or normalise it — + * the only transformation is the server appending `\r` when the caller passed `appendEnter = true`. + */ + +/** + * `GET /live-sessions/:id/queue` 200 → `{ length, items }` — the pending depth plus the queued texts + * (verbatim keystroke bytes). Both fields default, so a missing/garbled key degrades to an empty queue + * instead of throwing; a non-object body yields `null` from `LossyDecode` (→ `InvalidResponseBody`). + */ +@Serializable +public data class QueueSnapshot( + /** Pending entries on the server. Equals `items.size` for a healthy server; trust [items] for + * rendering and [length] only as the badge number the server itself broadcasts. */ + val length: Int = 0, + /** The queued prompts in FIFO order, verbatim. */ + val items: List = emptyList(), +) + +/** + * Outcome of the two GUARDED queue writes — `POST` (enqueue) and `DELETE` (cancel-all). One union for + * both ops (the `GitWriteOutcome` precedent: one union, six routes); [Full], [TooLarge] and [Disabled] + * are simply unreachable for the DELETE, whose handler neither validates text nor consults the + * `QUEUE_ENABLED` kill-switch. + * + * Every variant maps to different UI behaviour, which is why they are typed rather than folded into a + * status code: + * - [Ok] — the server accepted it; [length] is the authoritative new depth for the "N queued" badge. + * - [Full] — 409, the bounded FIFO is at `QUEUE_MAX_ITEMS` (default 10). Cancel something first; + * the server never silently drops. + * - [TooLarge] — 413, over `QUEUE_ITEM_MAX_BYTES` (default 4 KiB, server-configurable — the client + * deliberately does NOT pre-validate size, which would invent a policy it cannot know). + * - [Disabled] — 503, `QUEUE_ENABLED=0` on this host: hide the affordance, do not retry. + * - [SessionGone] — 404, unknown or already-exited session. + * - [RateLimited] — 429 from the shared `QUEUE_RATE_MAX = 20`/minute/IP bucket. **Never auto-retry**; + * a retry loop just burns the same budget a real enqueue needs. + * - [Rejected] — any other 4xx/5xx, carrying the server's SAFE `error` string verbatim (`message` may + * be `null` when the body is empty, e.g. the Origin guard's bare 403). + */ +public sealed interface QueueWriteOutcome { + /** 200 — accepted; [length] is the new pending depth (0 for a cleared queue). */ + public data class Ok(val length: Int) : QueueWriteOutcome + + /** 409 — the bounded FIFO is full (`QUEUE_MAX_ITEMS`). */ + public data object Full : QueueWriteOutcome + + /** 413 — the prompt exceeds the server's per-item byte cap. */ + public data object TooLarge : QueueWriteOutcome + + /** 503 — the queue feature is switched off on this host. */ + public data object Disabled : QueueWriteOutcome + + /** 404 — the session is unknown or has already exited. */ + public data object SessionGone : QueueWriteOutcome + + /** 429 — shared per-IP queue bucket. Do NOT auto-retry. */ + public data object RateLimited : QueueWriteOutcome + + /** Any other failure, with the server's inert message (null when unparseable/empty). */ + public data class Rejected(val status: Int, val message: String?) : QueueWriteOutcome +} + +/** + * Read the new depth out of a queue write's 200 body (`{ length }`). The status already says it + * succeeded, so a missing/garbled body degrades to `0` rather than throwing — the next `queue` read or + * `queue` WS frame re-establishes the true depth. + */ +internal fun decodeQueueDepth(bytes: ByteArray): Int = + LossyDecode.objectOrNull(bytes, QueueSnapshot.serializer())?.length ?: 0 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt index 2a6df4e..54fab0b 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt @@ -2,6 +2,8 @@ package wang.yaojia.webterm.api.models import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor @@ -9,7 +11,9 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.intOrNull import wang.yaojia.webterm.wire.ClaudeStatus import java.util.UUID @@ -36,6 +40,23 @@ internal object ClaudeStatusSerializer : KSerializer { override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire) } +/** + * A tolerant `Int?` field decoder: a wrong-typed/garbled value degrades to `null` instead of failing + * the whole enclosing object. Used for ADDITIVE OPTIONAL numeric fields (e.g. + * `LiveSessionInfo.queueLength`) where dropping the entry would hide a running session for the sake of + * a badge. Required numeric fields deliberately keep the strict built-in behaviour. + */ +internal object LossyIntSerializer : KSerializer { + private val delegate: KSerializer = Int.serializer().nullable + override val descriptor: SerialDescriptor = delegate.descriptor + override fun serialize(encoder: Encoder, value: Int?) = delegate.serialize(encoder, value) + override fun deserialize(decoder: Decoder): Int? { + val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder) + val primitive = json.decodeJsonElement() as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content.toIntOrNull() else primitive.intOrNull + } +} + /** * A `List` serializer that drops malformed elements and degrades a non-array to `[]` — the * Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`, diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt index 4e2b0a3..e4e6035 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt @@ -11,6 +11,9 @@ import wang.yaojia.webterm.api.models.PrStatus import wang.yaojia.webterm.api.models.ProjectDetail import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.PruneWorktreesResult +import wang.yaojia.webterm.api.models.QueueSnapshot +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.api.models.decodeQueueDepth import wang.yaojia.webterm.api.models.FetchResult import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.RemoveWorktreeResult @@ -137,6 +140,19 @@ public class ApiClient( } } + /** + * `GET /live-sessions/:id/queue` (W2) — the pending prompt queue: depth + the queued texts, + * verbatim. READ-ONLY, so no Origin (the server guards only the two writes). 404 → the session is + * gone; a non-object body → [ApiClientError.InvalidResponseBody] rather than a fake empty queue + * (claiming "nothing queued" when entries exist would mislead a cancel-all decision). + */ + public suspend fun queue(id: UUID): QueueSnapshot { + val response = perform(Endpoints.queue(id)) + requireOk(response) + return LossyDecode.objectOrNull(response.body, QueueSnapshot.serializer()) + ?: throw ApiClientError.InvalidResponseBody + } + /** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws * `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */ public suspend fun prefs(): UiPrefs { @@ -182,6 +198,52 @@ public class ApiClient( } } + // ── G: W2 prompt queue (enqueue / cancel-all) → QueueWriteOutcome ────────────────────── + + /** + * `POST /live-sessions/:id/queue` (W2) — queue a follow-up prompt the server injects into the PTY + * the next time Claude goes idle. Works with zero tabs attached, which is the whole point. + * + * [text] travels VERBATIM (invariant #9 — raw keyboard bytes for a shell); [appendEnter] is a flag + * the SERVER turns into a trailing `\r`, so callers must not append one. An empty prompt is + * rejected as [ApiClientError.QueueTextEmpty] before any network I/O; size is NOT pre-checked + * client-side (the byte cap is server config) — an over-cap prompt comes back as + * [QueueWriteOutcome.TooLarge]. + */ + public suspend fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean = true): QueueWriteOutcome { + if (text.isEmpty()) throw ApiClientError.QueueTextEmpty + return queueWrite(Endpoints.enqueueFollowup(id, text, appendEnter)) + } + + /** + * `DELETE /live-sessions/:id/queue` (W2) — cancel every pending entry (escape hatch). 200 → + * [QueueWriteOutcome.Ok] with depth 0. + */ + public suspend fun clearQueue(id: UUID): QueueWriteOutcome = queueWrite(Endpoints.clearQueue(id)) + + /** + * Shared status mapping for the two guarded queue writes. Distinct typed outcomes because each one + * demands different UI behaviour (see [QueueWriteOutcome]); in particular a 429 from the shared + * `QUEUE_RATE_MAX = 20`/min/IP bucket is surfaced, NEVER auto-retried. Only 409/413/503 are + * POST-specific — the DELETE handler cannot produce them. + */ + private suspend fun queueWrite(route: ApiRoute): QueueWriteOutcome { + val response = perform(route) + return when (response.status) { + HttpStatus.OK -> QueueWriteOutcome.Ok(decodeQueueDepth(response.body)) + HttpStatus.CONFLICT -> QueueWriteOutcome.Full + HttpStatus.PAYLOAD_TOO_LARGE -> QueueWriteOutcome.TooLarge + HttpStatus.SERVICE_UNAVAILABLE -> QueueWriteOutcome.Disabled + HttpStatus.NOT_FOUND -> QueueWriteOutcome.SessionGone + HttpStatus.TOO_MANY_REQUESTS -> QueueWriteOutcome.RateLimited + // `decodeGitError` reads the generic `{ error }` failure shape the queue routes share with + // the git ones (400/403 here) — reused rather than duplicated. + in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX -> + QueueWriteOutcome.Rejected(response.status, decodeGitError(response.body)) + else -> throw ApiClientError.UnexpectedStatus(response.status) + } + } + // ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ────────────── /** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */ diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt index 3bd6d2f..2104c86 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt @@ -44,6 +44,11 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u /** 500 from `GET /projects/detail` — the server failed reading the repo. */ public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。") + /** An empty follow-up prompt was passed to `POST /live-sessions/:id/queue` (which the server 400s + * as `text must be a non-empty string`). Raised client-side, before any network I/O — mirrors the + * [ProjectPathInvalid] precedent. */ + public data object QueueTextEmpty : ApiClientError("要排队的内容为空——请先输入要发送的提示词。") + /** 500 from `GET /projects/log` — the server failed reading the git log. */ public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。") diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt index cbddb4c..9c795bb 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt @@ -12,8 +12,11 @@ internal object HttpStatus { const val BAD_REQUEST = 400 const val FORBIDDEN = 403 const val NOT_FOUND = 404 + const val CONFLICT = 409 + const val PAYLOAD_TOO_LARGE = 413 const val TOO_MANY_REQUESTS = 429 const val INTERNAL_SERVER_ERROR = 500 + const val SERVICE_UNAVAILABLE = 503 /** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */ const val CLIENT_ERROR_MIN = 400 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt index eb27f8f..4fdbfca 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt @@ -12,10 +12,10 @@ import java.util.UUID * `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token** * pair (this client uses FCM, not APNs/VAPID). * - * RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui` - * · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs` - * G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs` - * · `POST|DELETE /push/fcm-token` + * RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue` + * · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs` + * G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue` + * · `POST /hook/decision` · `PUT /prefs` · `POST|DELETE /push/fcm-token` * * KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD * of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` + @@ -70,6 +70,13 @@ internal object Endpoints { fun getPrefs(): ApiRoute = ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY) + /** + * `GET /live-sessions/:id/queue` — RO (W2). Same threat model as `GET /live-sessions`, so the + * server stamps no Origin guard on it and neither may we. + */ + fun queue(id: UUID): ApiRoute = + ApiRoute(HttpMethod.GET, queuePath(id), OriginPolicy.READ_ONLY) + /** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */ fun projectPr(path: String): ApiRoute = ApiRoute( @@ -175,6 +182,36 @@ internal object Endpoints { fun gitPush(path: String): ApiRoute = jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path)) + // ── G: W2 prompt queue (enqueue / cancel-all) ────────────────────────────────────────── + + /** + * `POST /live-sessions/:id/queue` — G. Body is exactly `{ text, appendEnter }` (express.json, + * 16 kb limit). + * + * [text] is RAW KEYBOARD INPUT for a shell and is carried VERBATIM (invariant #9): JSON string + * encoding is the only transformation, and it round-trips byte-exactly through `express.json`. + * Never trim/escape/normalise it here. [appendEnter] is a FLAG — the server materialises the + * trailing `\r` so the stored entry is byte-identical to a keystroke; the client must not append + * one itself. + */ + fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean): ApiRoute = + jsonBodyRoute( + HttpMethod.POST, + queuePath(id), + EnqueueBody.serializer(), + EnqueueBody(text, appendEnter), + ) + + /** + * `DELETE /live-sessions/:id/queue` — G, cancel-all escape hatch. Carries **no** body: unlike + * `DELETE /projects/worktree` (the body-carrying DELETE precedent), this handler reads only `:id` + * and clears the whole FIFO, so a body would be dead weight the server never parses. + */ + fun clearQueue(id: UUID): ApiRoute = + ApiRoute(HttpMethod.DELETE, queuePath(id), OriginPolicy.GUARDED) + + private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue" + /** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */ private fun jsonBodyRoute( method: HttpMethod, @@ -242,4 +279,7 @@ internal object Endpoints { @kotlinx.serialization.Serializable private data class FetchBody(val path: String) + + @Serializable + private data class EnqueueBody(val text: String, val appendEnter: Boolean) } diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt index 4deadf2..51b95fd 100644 --- a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/DeviceEnrollmentClientTest.kt @@ -253,6 +253,137 @@ class DeviceEnrollmentClientTest { assertTrue(transport.recordedRequests.isEmpty()) } + @Test + fun renewDecodesTheRealServerResponseWhichCarriesNoDeviceId() = runTest { + // THE REAL WIRE SHAPE: `/device/:id/renew` answers `{cert, caChain, notAfter}` — it does NOT + // echo deviceId/notBefore/renewAfter (only `/device/enroll` does; see + // control-plane/src/api/renew.ts). Decoding it against the enroll DTO (deviceId required) + // made every silent renewal fail as MalformedResponse. The device id comes from the path we + // called, which is authoritative anyway. + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, + body = """{"cert":"MAEC","caChain":["MAED"],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(), + ) + + val result = client.renew("dev-9", byteArrayOf(0x02)) + + assertEquals("dev-9", result.deviceId, "the device id comes from the path segment we called") + assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter) + assertNull(result.notBefore, "the renew 201 carries no notBefore") + assertNull(result.renewAfter, "the renew 201 carries no renewAfter — the client derives it") + assertEquals(1, result.caChain.size) + } + + // ── recover (EXPIRED-leaf escape hatch — plain HTTPS, cert in the BODY, never mTLS) ────────── + + @Test + fun recoverPostsTheExpiredLeafAndAFreshCsrWithNoAuthorizationHeader() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201, + body = """{"cert":"MAEC","caChain":[],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(), + ) + val expiredLeaf = byteArrayOf(0x30, 0x11) + val csr = byteArrayOf(0x30, 0x22) + + val result = client.recover("dev-9", expiredLeaf, csr) + + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/device/dev-9/recover", request.url) + // Recovery is NOT mTLS-authenticated and carries no bearer: an expired client certificate + // cannot authenticate its own re-issuance (no TLS terminator will even forward one), so the + // lapsed cert travels in the BODY and proof-of-possession comes from the CSR self-signature. + assertNull(request.headers["Authorization"], "recovery carries no bearer") + val obj = bodyObject(request) + assertEquals(setOf("cert", "csr"), obj.keys, "the /recover schema is .strict() on {cert, csr}") + assertEquals(Base64.getEncoder().encodeToString(expiredLeaf), obj["cert"]!!.jsonPrimitive.content) + assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content) + assertEquals("dev-9", result.deviceId) + assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter) + } + + @Test + fun recoverPercentEncodesTheDeviceIdPathSegment() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/a%2Fb/recover", status = 201, + body = """{"cert":"MAEC","caChain":[]}""".toByteArray(), + ) + client.recover("a/b", byteArrayOf(1), byteArrayOf(2)) + assertEquals("$BASE/device/a%2Fb/recover", transport.recordedRequests.single().url) + } + + @Test + fun recoverRejectsEmptyArgumentsBeforeAnyNetworkIo() = runTest { + assertEquals( + DeviceEnrollmentError.InvalidRequest, + runCatching { client.recover("", byteArrayOf(1), byteArrayOf(1)) }.exceptionOrNull(), + ) + assertEquals( + DeviceEnrollmentError.InvalidRequest, + runCatching { client.recover("d", ByteArray(0), byteArrayOf(1)) }.exceptionOrNull(), + ) + assertEquals( + DeviceEnrollmentError.InvalidRequest, + runCatching { client.recover("d", byteArrayOf(1), ByteArray(0)) }.exceptionOrNull(), + ) + assertTrue(transport.recordedRequests.isEmpty(), "no I/O for a request that cannot succeed") + } + + @Test + fun recoverSurfacesA401BeyondTheGraceWindowWithoutLeakingTheCert() = runTest { + // Past `DEFAULT_EXPIRED_RENEW_GRACE_MS` the control plane answers a uniform 401. The raised + // error must carry the STATUS + the server's `error` code only — never the submitted cert or + // CSR bytes, which would put credential material into a crash log. + val expiredLeaf = byteArrayOf(0x30, 0x11, 0x22, 0x33) + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 401, + body = """{"error":"rejected"}""".toByteArray(), + ) + + val error = runCatching { client.recover("dev-9", expiredLeaf, byteArrayOf(0x30, 0x44)) }.exceptionOrNull() + + assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error) + val rendered = "${error!!.message} $error" + assertFalse( + rendered.contains(Base64.getEncoder().encodeToString(expiredLeaf)), + "the raised error must not carry the certificate bytes", + ) + assertFalse(rendered.contains("MDE"), "no base64 credential material in the error rendering") + } + + @Test + fun recoverSurfacesA403ForARevokedDevice() = runTest { + // Recovery NEVER bypasses revocation (renew.test.ts CP6e) — surface the 403 verbatim. + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 403, + body = """{"error":"rejected"}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.Http(403, "rejected"), + runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(), + ) + } + + @Test + fun recoverThrowsMalformedResponseOnAnUndecodable201() = runTest { + transport.queueSuccess( + method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201, + body = """{"cert":"@@not-base64@@"}""".toByteArray(), + ) + assertEquals( + DeviceEnrollmentError.MalformedResponse, + runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(), + ) + } + + @Test + fun theBaseUrlIsReadableSoTheRotationTargetCanBePersisted() = runTest { + // The rotation driver must renew against the SAME control plane the device enrolled with; the + // enroller persists this alongside the enrollment record. + assertEquals(BASE, client.baseUrl) + assertEquals(BASE, DeviceEnrollmentClient("$BASE/", transport).baseUrl, "a trailing slash is trimmed") + } + // ── isRenewalDue seam ────────────────────────────────────────────────────────────────────── @Test diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicyTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicyTest.kt new file mode 100644 index 0000000..7b7b315 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/enroll/RotationPolicyTest.kt @@ -0,0 +1,228 @@ +package wang.yaojia.webterm.api.enroll + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Duration +import java.time.Instant + +/** + * The PURE rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`, + * `TerminalGridMath` — a policy is a function, so it is exhaustively testable with no clock, no + * keystore and no network). Ports the iOS `CertificateRotationSchedulerTests` timing cases and adds + * the two the phone track needs and iOS lacks: the EXPIRED-leaf `/device/:id/recover` window and the + * terminal beyond-grace state. + */ +class RotationPolicyTest { + private companion object { + val NOT_BEFORE: Instant = Instant.parse("2026-08-06T00:00:00Z") + val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z") + // notBefore + 2/3 · 61d lifetime — the value the control plane itself computes. + val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z") + + fun state( + notAfter: Instant? = NOT_AFTER, + renewAfter: Instant? = RENEW_AFTER, + ): DeviceRenewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter) + } + + // ── renewAfter derivation (the client MUST derive it: renew/recover 201s carry no renewAfter) ── + + @Test + fun renewAfterIsTwoThirdsThroughTheLeafLifetime() { + // The control plane computes `notBefore + floor(2/3 · lifetime)` (device-enroll.ts + // DEFAULT_RENEW_FRACTION); the client must land on the same instant from the leaf alone, + // because `/device/:id/renew` and `/device/:id/recover` return `{cert, caChain, notAfter}` + // ONLY — no renewAfter. Without this derivation rotation would fire exactly once, ever. + assertEquals(RENEW_AFTER, RotationPolicy.renewAfterFor(NOT_BEFORE, NOT_AFTER)) + } + + @Test + fun renewAfterIsNullWhenEitherBoundIsUnknown() { + assertNull(RotationPolicy.renewAfterFor(null, NOT_AFTER)) + assertNull(RotationPolicy.renewAfterFor(NOT_BEFORE, null)) + } + + @Test + fun renewAfterIsNullForANonsensicalWindow() { + // notAfter before notBefore is not a lifetime — degrade to "unknown" (fail-safe: the TLS + // stack is the real gate) instead of inventing a past instant that would renew-storm. + assertNull(RotationPolicy.renewAfterFor(NOT_AFTER, NOT_BEFORE)) + } + + // ── the wait branch ──────────────────────────────────────────────────────────────────────── + + @Test + fun aLeafInsideItsRenewWindowIsNotDue() { + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide(state(), now = RENEW_AFTER.minusSeconds(1)), + ) + } + + @Test + fun aMissingRenewAfterNeverTriggers() { + // iOS fail-safe, ported verbatim: absent timing NEVER renews — the TLS stack is the real gate + // and the scheduler only pre-empts expiry. + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide(state(renewAfter = null), now = NOT_AFTER.minusSeconds(1)), + ) + assertFalse(state(renewAfter = null).isRenewalDue(NOT_AFTER.minusSeconds(1))) + } + + @Test + fun anUnknownNotAfterFallsBackToTheRenewAfterBranch() { + // A cert whose expiry could not be read must not be treated as expired (that would push a + // working device into recovery); it is judged solely on the advisory renew timing. + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER.minusSeconds(1)), + ) + assertEquals( + RotationDecision.RENEW, + RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER), + ) + } + + // ── the renew branch ─────────────────────────────────────────────────────────────────────── + + @Test + fun renewFiresAtTheRenewAfterBoundary() { + assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = RENEW_AFTER)) + assertTrue(state().isRenewalDue(RENEW_AFTER), "the >= boundary renews (iOS parity)") + } + + @Test + fun renewStillFiresAtTheLastInstantBeforeExpiry() { + assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = NOT_AFTER)) + } + + // ── the recover branch (expired leaf — the phone-track deadlock escape hatch) ─────────────── + + @Test + fun anExpiredLeafInsideTheGraceWindowRecovers() { + // mTLS `/renew` cannot authenticate an expired leaf (nginx refuses to forward one at all), so + // the only route left is the plain-HTTPS `/device/:id/recover`. + assertEquals( + RotationDecision.RECOVER, + RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(8))), + ) + } + + @Test + fun recoveryIsStillAvailableAtTheExactGraceBoundary() { + val grace = Duration.ofDays(30) + assertEquals( + RotationDecision.RECOVER, + RotationPolicy.decide(state(), now = NOT_AFTER.plus(grace), expiredRecoveryGrace = grace), + ) + } + + @Test + fun aLeafExpiredBeyondTheGraceWindowRequiresAFreshEnroll() { + assertEquals( + RotationDecision.RE_ENROLL_REQUIRED, + RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(31))), + ) + } + + @Test + fun aZeroGraceDisablesRecoveryEntirely() { + // Mirrors the control plane's `expiredRenewGraceMs = 0` posture (renew.test.ts CP6e). + assertEquals( + RotationDecision.RE_ENROLL_REQUIRED, + RotationPolicy.decide( + state(), + now = NOT_AFTER.plusMillis(1), + expiredRecoveryGrace = Duration.ZERO, + ), + ) + } + + @Test + fun theTerminalStateWinsOverTheFailureBackoff() { + // Beyond grace nothing can ever succeed, so the answer must be the terminal one even while a + // backoff is armed — otherwise the user is told "retrying" forever (the exact failure mode + // the agent's 6380 identical warnings came from). + assertEquals( + RotationDecision.RE_ENROLL_REQUIRED, + RotationPolicy.decide( + state(), + now = NOT_AFTER.plus(Duration.ofDays(31)), + lastFailureAt = NOT_AFTER.plus(Duration.ofDays(31)).minusSeconds(1), + ), + ) + } + + // ── the backoff branch (a failed attempt must not hammer the control plane) ───────────────── + + @Test + fun aRecentFailureBacksOffInsteadOfRetryingImmediately() { + // Every foreground fires a pass; the control plane rate-limits renewals to 30/hour/identity, + // so an un-throttled retry turns one failure into a 429 storm. + val now = RENEW_AFTER.plus(Duration.ofMinutes(1)) + assertEquals( + RotationDecision.BACKING_OFF, + RotationPolicy.decide(state(), now = now, lastFailureAt = now.minus(Duration.ofMinutes(1))), + ) + } + + @Test + fun theBackoffExpiresAndTheRenewIsRetried() { + val now = RENEW_AFTER.plus(Duration.ofHours(1)) + assertEquals( + RotationDecision.RENEW, + RotationPolicy.decide( + state(), + now = now, + lastFailureAt = now.minus(RotationPolicy.DEFAULT_FAILURE_BACKOFF), + ), + ) + } + + @Test + fun aRecentFailureAlsoThrottlesTheRecoveryPath() { + val now = NOT_AFTER.plus(Duration.ofDays(8)) + assertEquals( + RotationDecision.BACKING_OFF, + RotationPolicy.decide(state(), now = now, lastFailureAt = now.minusSeconds(30)), + ) + } + + @Test + fun aFailureNeverTurnsAnIdleLeafIntoAnAttempt() { + // NOT_DUE has no attempt to throttle — reporting BACKING_OFF there would be a lie. + assertEquals( + RotationDecision.NOT_DUE, + RotationPolicy.decide( + state(), + now = RENEW_AFTER.minusSeconds(1), + lastFailureAt = RENEW_AFTER.minusSeconds(2), + ), + ) + } + + // ── boundary validation ──────────────────────────────────────────────────────────────────── + + @Test + fun negativeDurationsAreRejectedAtTheBoundary() { + assertThrows(IllegalArgumentException::class.java) { + RotationPolicy.decide(state(), now = RENEW_AFTER, expiredRecoveryGrace = Duration.ofDays(-1)) + } + assertThrows(IllegalArgumentException::class.java) { + RotationPolicy.decide(state(), now = RENEW_AFTER, failureBackoff = Duration.ofMinutes(-1)) + } + } + + @Test + fun theStateCarriesNoCredentialInItsToString() { + // The rotation state is logged/surfaced; it must never be able to carry key or cert bytes. + val rendered = state().toString() + assertTrue(rendered.contains("dev-1"), "the non-secret device id is the only identifier here") + assertFalse(rendered.contains("MII"), "no DER/PEM material may appear in a loggable rendering") + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PromptQueueTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PromptQueueTest.kt new file mode 100644 index 0000000..034c531 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PromptQueueTest.kt @@ -0,0 +1,74 @@ +package wang.yaojia.webterm.api.models + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * Tolerant decode for the W2 queue read (`GET /live-sessions/:id/queue` → `{length, items}`) and for + * the additive `LiveSessionInfo.queueLength` the badge reads. The server is UNTRUSTED here: unknown + * keys, missing keys and wrong-typed items must degrade, never crash — and the queued strings, which + * are raw keyboard bytes, must come back verbatim. + */ +@DisplayName("W2 queue models — tolerant decode + verbatim items") +class PromptQueueTest { + private fun snapshot(json: String): QueueSnapshot? = + LossyDecode.objectOrNull(json.toByteArray(), QueueSnapshot.serializer()) + + @Test + fun `decodes length and items`() { + val s = snapshot("""{"length":2,"items":["first","second"]}""")!! + assertEquals(2, s.length) + assertEquals(listOf("first", "second"), s.items) + } + + @Test + fun `queued items keep control characters and CJK verbatim`() { + //  + TAB + CR + CJK, exactly as the server stored the keystroke bytes. + val s = snapshot("""{"length":1,"items":["\t你好\r"]}""")!! + assertEquals("\t你好\r", s.items.single()) + } + + @Test + fun `an empty queue and unknown keys both decode`() { + val s = snapshot("""{"length":0,"items":[],"futureField":true}""")!! + assertEquals(0, s.length) + assertTrue(s.items.isEmpty()) + } + + @Test + fun `missing fields fall back to an empty queue and a non-object body is null`() { + val s = snapshot("{}")!! + assertEquals(0, s.length) + assertTrue(s.items.isEmpty()) + + assertNull(snapshot("[]")) + assertNull(snapshot("garbage")) + } + + // ── LiveSessionInfo.queueLength (additive optional, src/types.ts:318) ───────────────────── + + private val base = + """{"id":"11111111-2222-3333-4444-555555555555","createdAt":1,"clientCount":0,"exited":false,"cols":80,"rows":24""" + + private fun info(extra: String): LiveSessionInfo? = + LossyDecode.objectOrNull("$base$extra}".toByteArray(), LiveSessionInfo.serializer()) + + @Test + fun `queueLength decodes when present`() { + assertEquals(4, info(""","queueLength":4""")!!.queueLength) + } + + @Test + fun `queueLength is null on a pre-W2 server that omits it`() { + assertNull(info("")!!.queueLength) + } + + @Test + fun `a wrong-typed queueLength does not drop the whole session`() { + // A garbled value must not make a running session invisible — the field degrades to null. + assertNull(info(",\"queueLength\":\"lots\"")!!.queueLength) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientQueueTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientQueueTest.kt new file mode 100644 index 0000000..0c494ac --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientQueueTest.kt @@ -0,0 +1,141 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import java.util.UUID + +/** + * Status → outcome mapping for the W2 prompt queue, read off the server handlers + * (`src/server.ts` ~605/644/654): + * + * POST: 200 `{length}` → [QueueWriteOutcome.Ok] · 409 `{error:"full"}` → `Full` · + * 413 → `TooLarge` · 503 `{error:"queue disabled"}` → `Disabled` · + * 404 `{error:"unknown"|"exited"}` → `SessionGone` · 429 (empty) → `RateLimited` · + * 400/403/anything else → `Rejected(status, safe error)`. + * DELETE: 200 `{length:0}` → `Ok(0)` · 404 → `SessionGone` · 403 → `Rejected`. + * GET: 200 → snapshot · 404 → [ApiClientError.SessionNotFound]. + * + * 429 comes from the shared `QUEUE_RATE_MAX = 20`/min/IP bucket and is a DISTINCT outcome precisely + * so no caller auto-retries into it (the `GitWriteOutcome.RateLimited` precedent). + */ +@DisplayName("W2 queue — status mapping") +class ApiClientQueueTest { + private companion object { + const val BASE = "http://h:3000" + val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555") + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + private val queueUrl = "$BASE/live-sessions/$ID/queue" + + private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull() + + private fun queuePost(status: Int, body: String = "") = + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, status = status, body = body.toByteArray()) + + private fun queueDelete(status: Int, body: String = "") = + transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, status = status, body = body.toByteArray()) + + private suspend fun enqueue(text: String = "do the thing") = client.enqueueFollowup(ID, text, appendEnter = true) + + // ── POST ────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a 200 yields Ok carrying the new depth`() = runTest { + queuePost(200, """{"length":3}""") + assertEquals(QueueWriteOutcome.Ok(3), enqueue()) + } + + @Test + fun `a 200 with a garbled body still succeeds with an unknown depth of zero`() = runTest { + queuePost(200, "not json") + assertEquals(QueueWriteOutcome.Ok(0), enqueue()) + } + + @Test + fun `409 full 413 too-large and 503 disabled are distinct typed outcomes`() = runTest { + queuePost(409, """{"error":"full"}""") + assertEquals(QueueWriteOutcome.Full, enqueue()) + + queuePost(413, """{"error":"text too large"}""") + assertEquals(QueueWriteOutcome.TooLarge, enqueue()) + + queuePost(503, """{"error":"queue disabled"}""") + assertEquals(QueueWriteOutcome.Disabled, enqueue()) + } + + @Test + fun `404 unknown or exited is SessionGone`() = runTest { + queuePost(404, """{"error":"unknown"}""") + assertEquals(QueueWriteOutcome.SessionGone, enqueue()) + + queuePost(404, """{"error":"exited"}""") + assertEquals(QueueWriteOutcome.SessionGone, enqueue()) + } + + @Test + fun `429 from the shared 20-per-minute bucket is RateLimited and nothing is retried`() = runTest { + queuePost(429) + assertEquals(QueueWriteOutcome.RateLimited, enqueue()) + // Exactly ONE request went out — a rate-limited enqueue must never auto-retry. + assertEquals(1, transport.recordedRequests.size) + } + + @Test + fun `400 and 403 surface Rejected with the server's safe message`() = runTest { + queuePost(400, """{"error":"text must be a non-empty string"}""") + assertEquals(QueueWriteOutcome.Rejected(400, "text must be a non-empty string"), enqueue()) + + // The Origin guard 403s with an EMPTY body — message degrades to null, never throws. + queuePost(403) + assertEquals(QueueWriteOutcome.Rejected(403, null), enqueue()) + } + + @Test + fun `an odd non-error status is UnexpectedStatus`() = runTest { + queuePost(302) + assertTrue(errorOf { enqueue() } is ApiClientError.UnexpectedStatus) + } + + @Test + fun `an empty prompt is rejected before any network IO`() = runTest { + assertEquals(ApiClientError.QueueTextEmpty, errorOf { client.enqueueFollowup(ID, "", appendEnter = true) }) + assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty prompt") + } + + // ── DELETE ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun `clearQueue maps 200 404 403 and 429`() = runTest { + queueDelete(200, """{"length":0}""") + assertEquals(QueueWriteOutcome.Ok(0), client.clearQueue(ID)) + + queueDelete(404) + assertEquals(QueueWriteOutcome.SessionGone, client.clearQueue(ID)) + + queueDelete(403) + assertEquals(QueueWriteOutcome.Rejected(403, null), client.clearQueue(ID)) + + queueDelete(429) + assertEquals(QueueWriteOutcome.RateLimited, client.clearQueue(ID)) + } + + // ── GET ─────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `queue maps 404 to SessionNotFound and a garbled 200 to InvalidResponseBody`() = runTest { + transport.queueSuccess(url = queueUrl, status = 404) + assertEquals(ApiClientError.SessionNotFound, errorOf { client.queue(ID) }) + + transport.queueSuccess(url = queueUrl, body = "[]".toByteArray()) + assertEquals(ApiClientError.InvalidResponseBody, errorOf { client.queue(ID) }) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/QueueRouteShapeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/QueueRouteShapeTest.kt new file mode 100644 index 0000000..7c72778 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/QueueRouteShapeTest.kt @@ -0,0 +1,162 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import java.util.UUID + +/** + * Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the W2 prompt-queue trio + * (`src/server.ts` ~605/644/654): + * + * - `GET /live-sessions/:id/queue` — read-only ⇒ MUST NOT carry Origin; + * - `POST /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin + JSON body; + * - `DELETE /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin, and (unlike + * `DELETE /projects/worktree`, the precedent that DOES carry a body) MUST NOT carry a body — the + * server handler reads nothing but `:id` and clears the whole queue. + * + * A route reclassified read↔write turns this red instead of silently shipping either a CSWSH hole or a + * write the server 403s. + * + * The enqueued text is RAW KEYBOARD INPUT for a shell (invariant #9): it must survive the JSON body + * **verbatim** — no trim, no escape-rewrite, no Unicode normalisation, and no client-side `\r` + * (`appendEnter` is a flag the SERVER materialises). + */ +@DisplayName("W2 queue routes — shape, Origin policy, verbatim body") +class QueueRouteShapeTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val ORIGIN = "http://192.168.1.5:3000" + val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555") + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + + private val queueUrl = "$BASE/live-sessions/$ID/queue" + + private fun last(): HttpRequest = transport.recordedRequests.last() + + private fun assertGuarded(r: HttpRequest) = + assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin") + + private fun assertReadOnly(r: HttpRequest) = + assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin") + + /** Parse the recorded request body back out of JSON — proves round-trip byte-exactness without + * pinning any particular escaping strategy. */ + private fun bodyText(r: HttpRequest): String? = + Json.parseToJsonElement(r.body!!.decodeToString()).jsonObject["text"]?.jsonPrimitive?.content + + // ── GET — read-only, no Origin ──────────────────────────────────────────────────────────── + + @Test + fun `queue is a read-only GET with no Origin and no body`() = runTest { + transport.queueSuccess(url = queueUrl, body = """{"length":2,"items":["a","b"]}""".toByteArray()) + + val snapshot = client.queue(ID) + + val r = last() + assertEquals(HttpMethod.GET, r.method) + assertEquals(queueUrl, r.url) + assertReadOnly(r) + assertNull(r.body, "a GET must not carry a body") + assertEquals(2, snapshot.length) + assertEquals(listOf("a", "b"), snapshot.items) + } + + // ── POST — guarded, JSON body ───────────────────────────────────────────────────────────── + + @Test + fun `enqueueFollowup is a guarded POST with a text-appendEnter body`() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + + client.enqueueFollowup(ID, "run the tests", appendEnter = true) + + val r = last() + assertEquals(HttpMethod.POST, r.method) + assertEquals(queueUrl, r.url) + assertGuarded(r) + assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE]) + assertEquals("""{"text":"run the tests","appendEnter":true}""", r.body?.decodeToString()) + } + + @Test + fun `appendEnter false is sent verbatim and the client never appends CR itself`() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + + client.enqueueFollowup(ID, "no enter", appendEnter = false) + + assertEquals("""{"text":"no enter","appendEnter":false}""", last().body?.decodeToString()) + assertEquals("no enter", bodyText(last())) + } + + @Test + fun `control characters and CJK survive the body byte-exactly`() = runTest { + // ESC[A (up-arrow), a literal TAB, ETX (Ctrl-C), CR, a stray NUL, CJK, an emoji, and + // deliberate surrounding whitespace that must NOT be trimmed. + val raw = " \u001B[A\t\u0003 \u0000 \u4F60\u597D\uFF0C\u4E16\u754C \uD83C\uDF0F caf\u00E9\r\n " + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + + client.enqueueFollowup(ID, raw, appendEnter = false) + + assertEquals(raw, bodyText(last()), "queued prompt bytes must travel verbatim (invariant #9)") + } + + // ── DELETE — guarded, and (unlike DELETE /projects/worktree) body-less ──────────────────── + + @Test + fun `clearQueue is a guarded DELETE with NO body`() = runTest { + transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray()) + + client.clearQueue(ID) + + val r = last() + assertEquals(HttpMethod.DELETE, r.method) + assertEquals(queueUrl, r.url) + assertGuarded(r) + assertNull(r.body, "DELETE /live-sessions/:id/queue takes no body (server reads only :id)") + assertFalse(r.headers.containsKey(HeaderName.CONTENT_TYPE), "no body ⇒ no Content-Type") + } + + // ── the batch invariant ─────────────────────────────────────────────────────────────────── + + @Test + fun `only the two writes carry Origin (batch invariant)`() = runTest { + transport.queueSuccess(url = queueUrl, body = """{"length":0,"items":[]}""".toByteArray()) + transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray()) + transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray()) + + client.queue(ID) + client.enqueueFollowup(ID, "x", appendEnter = true) + client.clearQueue(ID) + + assertReadOnly(transport.recordedRequests[0]) + assertGuarded(transport.recordedRequests[1]) + assertGuarded(transport.recordedRequests[2]) + assertNotNull(transport.recordedRequests[1].body) + } + + @Test + fun `session id is serialized lowercase in the path`() = runTest { + val upper = UUID.fromString("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE") + val url = "$BASE/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue" + transport.queueSuccess(url = url, body = """{"length":0,"items":[]}""".toByteArray()) + + client.queue(upper) + + assertTrue(last().url.endsWith("/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue")) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/QueuePanel.kt b/android/app/src/main/java/wang/yaojia/webterm/components/QueuePanel.kt new file mode 100644 index 0000000..1970b11 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/QueuePanel.kt @@ -0,0 +1,271 @@ +package wang.yaojia.webterm.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import wang.yaojia.webterm.designsystem.Radius +import wang.yaojia.webterm.designsystem.Spacing +import wang.yaojia.webterm.designsystem.WebTermTheme +import wang.yaojia.webterm.designsystem.WebTermType +import wang.yaojia.webterm.viewmodels.QueueNotice +import wang.yaojia.webterm.viewmodels.QueueUiState +import wang.yaojia.webterm.viewmodels.QueueViewModel + +/** + * # QueueBadge (W2) — the "N queued" pill. + * + * Renders NOTHING for a null (unknown / pre-W2 server) or non-positive depth, so a host without the + * queue feature shows no affordance at all and an empty queue does not shout "0". + * + * The number is always the server's own count — the live `queue` frame or `GET /live-sessions`'s + * `queueLength` — never a locally incremented guess. + */ +@Composable +public fun QueueBadge(depth: Int?, modifier: Modifier = Modifier) { + val pending = depth ?: return + if (pending <= 0) return + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(Radius.sm8)) + .padding(horizontal = Spacing.sm8, vertical = Spacing.xs2), + ) { + Text( + text = "$pending 待发", + style = WebTermType.metaMono, // tabular numerals so the pill does not jitter as N changes + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + ) + } +} + +/** + * # QueuePanel (W2) — queue / review / cancel the follow-up prompt FIFO. + * + * The Android counterpart of `public/queue.ts`: a bottom sheet that queues a prompt the server injects + * into the PTY the next time Claude goes idle, lists what is already pending, and offers the ONE cancel + * the server actually has — cancel-ALL. There is deliberately no per-item cancel affordance, because + * `DELETE /live-sessions/:id/queue` clears the whole FIFO and pretending otherwise would lie. + * + * ### The composed text is raw keyboard input (invariant #9) + * The field's contents go to [onSend] **verbatim** — this file never trims, normalises, or appends a + * `\r`. `appendEnter` is a FLAG on the request, so the SERVER materialises the carriage return + * byte-identically to a keystroke. The send action is enabled by [QueueViewModel.canSend], which refuses + * only a genuinely EMPTY string; whitespace is legitimate shell input. + * + * ### Untrusted text (plan §8) + * Queued items are round-tripped through the server, so they are rendered as INERT [Text] — no linkify, + * no Markdown, no `AnnotatedString` autolink — exactly like every other server-derived string. So is the + * [QueueNotice.Rejected] message, which is the server's own already-sanitized `error` field. + * + * Sheet presentation / detents / IME behaviour are device-QA (plan §7); the state machine is + * `QueueViewModelTest`. + * + * @param onSend the composed prompt, verbatim. The panel clears its field only after invoking this. + * @param onClearAll `DELETE …/queue` — cancel every pending entry. + * @param onRefresh re-read the queue (used when the live depth says the cached list is stale). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +public fun QueuePanel( + state: QueueUiState, + onSend: (String) -> Unit, + onClearAll: () -> Unit, + onDismissNotice: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onRefresh: () -> Unit = {}, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var draft by remember { mutableStateOf("") } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg16, vertical = Spacing.md12), + verticalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = "排队提示词", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + QueueBadge(depth = state.depth) + } + Text( + text = "Claude 下次空闲时,服务器会按顺序把它们送进终端。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + state.notice?.let { notice -> + QueueNoticeRow(notice = notice, onDismiss = onDismissNotice) + } + + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, // VERBATIM: no trim / normalisation anywhere on this path + label = { Text("要排队的提示词") }, + enabled = !state.isBusy, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 6, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + Button( + onClick = { + onSend(draft) // exactly what the user typed + draft = "" + }, + enabled = !state.isBusy && QueueViewModel.canSend(draft), + ) { Text("加入队列") } + TextButton(onClick = onClearAll, enabled = !state.isBusy && state.depth > 0) { + Text("全部取消") + } + } + + HorizontalDivider() + QueuedItems(state = state, onRefresh = onRefresh) + } + } +} + +/** The pending prompts, inert. A stale list offers a re-read rather than contradicting the badge. */ +@Composable +private fun QueuedItems(state: QueueUiState, onRefresh: () -> Unit) { + if (state.isItemsStale) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = "队列已变化。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onRefresh) { Text("刷新") } + } + return + } + if (state.items.isEmpty()) { + Text( + text = "队列是空的。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = QUEUE_LIST_MAX_HEIGHT), + verticalArrangement = Arrangement.spacedBy(Spacing.xs4), + ) { + itemsIndexed(state.items) { index, item -> + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + Text( + text = "${index + 1}.", + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // INERT: round-tripped through the server, so plain Text only (plan §8). + Text( + text = item, + style = WebTermType.metaMono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +/** + * One honest line per failure. A 429 says so plainly and offers NO retry button — the bucket is shared + * (20/min/IP), so a retry only burns the budget a real enqueue needs. + */ +@Composable +private fun QueueNoticeRow(notice: QueueNotice, onDismiss: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8)) + .padding(Spacing.sm8), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = queueNoticeText(notice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismiss) { Text("知道了") } + } +} + +/** + * App-authored copy for each outcome. [QueueNotice.Rejected] is the ONE case that surfaces a server + * string, and it is displayed inertly and verbatim (the server already sanitized it); a null message + * falls back to app copy rather than printing "null". + */ +internal fun queueNoticeText(notice: QueueNotice): String = when (notice) { + QueueNotice.Full -> "队列已满,请先取消一条。" + QueueNotice.TooLarge -> "提示词太长了,请缩短后重试。" + QueueNotice.Disabled -> "这台主机关闭了排队功能。" + QueueNotice.SessionGone -> "会话已退出,无法排队。" + // No retry offered: the 20/min bucket is shared with every other client of this host. + QueueNotice.RateLimited -> "操作过于频繁(每分钟上限 20 次),请稍后再手动重试。" + QueueNotice.Unreachable -> "无法连接主机,队列状态未知。" + is QueueNotice.Rejected -> notice.message ?: "主机拒绝了这次操作。" +} + +/** Keeps the sheet's buttons reachable no matter how long the queue gets. */ +private val QUEUE_LIST_MAX_HEIGHT = Spacing.xxl24 * 8 // 192dp + +// ── Preview ───────────────────────────────────────────────────────────────────── + +@Preview(name = "QueueBadge") +@Composable +private fun QueueBadgePreview() { + WebTermTheme { + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) { + QueueBadge(depth = 3) + QueueBadge(depth = 0) // renders nothing + QueueBadge(depth = null) // renders nothing + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt b/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt index 61e44e9..376444a 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt @@ -113,6 +113,10 @@ private fun TitleLine(row: SessionRow) { ) { StatusBadge(status = row.displayStatus) if (row.isUnread) UnreadDot() + // W2: the pending follow-up depth from `GET /live-sessions`'s `queueLength` (null on a pre-W2 + // server → renders nothing). The badge is the cold/list source; an ATTACHED session's live depth + // comes from the `queue` WS frame instead. + QueueBadge(depth = row.info.queueLength) Text( text = row.title.ifBlank { fallbackLabel(row.info) }, style = MaterialTheme.typography.bodyMedium, @@ -202,7 +206,12 @@ private fun SessionListRowPreview() { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) { SessionListRow( row = SessionRow( - info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L), + info = previewInfo( + status = ClaudeStatus.WORKING, + title = "web-terminal", + lastOutputAt = 2L, + queueLength = 2, + ), displayStatus = DisplayStatus.Working, title = "web-terminal", isUnread = true, @@ -228,6 +237,7 @@ private fun previewInfo( cols: Int = 161, rows: Int = 50, lastOutputAt: Long? = null, + queueLength: Int? = null, ): LiveSessionInfo = LiveSessionInfo( id = UUID.fromString("11111111-2222-4333-8444-555555555555"), createdAt = 1L, @@ -240,4 +250,5 @@ private fun previewInfo( rows = rows, telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L), lastOutputAt = lastOutputAt, + queueLength = queueLength, ) diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt index a5aed4d..b46fd44 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt @@ -1,11 +1,19 @@ package wang.yaojia.webterm.di +import com.google.firebase.messaging.FirebaseMessaging import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.suspendCancellableCoroutine +import wang.yaojia.webterm.push.PushRegistrar import wang.yaojia.webterm.push.PushRegistrarTokenSink import wang.yaojia.webterm.push.PushTokenSink +import wang.yaojia.webterm.wiring.HostPushUnregister +import wang.yaojia.webterm.wiring.PushTokenSource +import javax.inject.Singleton +import kotlin.coroutines.resume /** * Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with @@ -13,10 +21,52 @@ import wang.yaojia.webterm.push.PushTokenSink * `Optional` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)` * — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar] * (self-heal). This is the intended optional-collaborator handoff (no mutable global). + * + * It also binds the two seams the HOST-REMOVAL path needs + * ([HostPushUnregister] + [PushTokenSource]): `PushRegistrar.unregisterHost` had zero call sites, so a + * removed host kept this device's token and kept pushing for a host the user had dropped. */ @Module @InstallIn(SingletonComponent::class) public interface PushModule { @Binds public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink + + public companion object { + /** + * `DELETE /push/fcm-token` for ONE host — the FIRST caller of [PushRegistrar.unregisterHost], + * which was defined and never invoked, so a removed host kept this device's token forever. + * + * Routed through the registrar rather than a fresh `ApiClient` so the removal inherits its + * already-tested best-effort discipline (a `CancellationException` rethrown, any other failure + * swallowed with the token never logged). + */ + @Provides + @Singleton + public fun provideHostPushUnregister( + registrar: PushRegistrar, + ): HostPushUnregister = HostPushUnregister { host, token -> registrar.unregisterHost(host, token) } + + /** + * The current FCM registration token. Suspends on Firebase's own `Task` rather than blocking, and + * answers `null` for every degraded case (no `google-services.json`, uninitialised `FirebaseApp`, + * a failed fetch) so a push-less build still removes hosts cleanly. The token is returned to the + * caller and NEVER logged (plan §8). + */ + @Provides + @Singleton + public fun providePushTokenSource(): PushTokenSource = PushTokenSource { + suspendCancellableCoroutine { continuation -> + val task = runCatching { FirebaseMessaging.getInstance().token }.getOrNull() + if (task == null) { + continuation.resume(null) + return@suspendCancellableCoroutine + } + task + .addOnSuccessListener { token -> continuation.resume(token?.takeIf { it.isNotEmpty() }) } + .addOnFailureListener { continuation.resume(null) } + .addOnCanceledListener { continuation.resume(null) } + } + } + } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt index 0cf3920..5c03996 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt @@ -12,15 +12,22 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import wang.yaojia.webterm.hostregistry.DataStoreHostStore import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore +import wang.yaojia.webterm.hostregistry.DataStoreSessionWatermarkStore import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.LastSessionStore +import wang.yaojia.webterm.hostregistry.SessionWatermarkStore import javax.inject.Singleton /** * Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore. - * The host list ([HostStore], key `"hosts"`) and per-host last-session id ([LastSessionStore], key - * `"lastSessionId."`) use disjoint keys, so they safely share one DataStore file. Secret - * material (the device cert) never lives here — that is the Tink store in [TlsModule]. + * The host list ([HostStore], key `"hosts"`), the per-host last-session id ([LastSessionStore], key + * `"lastSessionId."`) and the unread watermarks ([SessionWatermarkStore], key + * `"unreadWatermarks"`) use disjoint keys, so they safely share one DataStore file. Secret material (the + * device cert, the `webterm_auth` cookie) never lives here in the clear — those are the Tink-sealed + * stores in [TlsModule] / [AuthCookieModule]. + * + * Two `DataStore` instances over one file THROW, so every store here takes the single [provideDataStore] + * singleton; a new store means a new KEY, never a new file. */ @Module @InstallIn(SingletonComponent::class) @@ -43,5 +50,16 @@ public object StorageModule { public fun provideLastSessionStore(dataStore: DataStore): LastSessionStore = DataStoreLastSessionStore(dataStore) + /** + * The durable home of the session-list unread watermarks (`sessionId → last-seen ms`). Until this was + * wired the ledger was memory-only, so the unread dot reset on every process death — precisely when + * it matters, since the product premise is walking away and coming back. + */ + @Provides + @Singleton + public fun provideSessionWatermarkStore( + dataStore: DataStore, + ): SessionWatermarkStore = DataStoreSessionWatermarkStore(dataStore) + private const val DATASTORE_NAME: String = "webterm" } diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt index 3a25ba3..22011bd 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt @@ -16,6 +16,8 @@ import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.TinkCertStore import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore +import wang.yaojia.webterm.wire.HttpTransport +import wang.yaojia.webterm.wiring.CertificateRotationScheduler import javax.inject.Singleton /** @@ -78,4 +80,35 @@ public object TlsModule { @Singleton public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher = repository + + /** + * Silent device-certificate rotation (the iOS `CertificateRotationScheduler` counterpart). Android had + * NO rotation at all, so a device certificate simply expired and stranded the app; the scheduler also + * routes an ALREADY-lapsed leaf to `POST /device/:id/recover`, which iOS cannot do. + * + * ### This provider must stay I/O-free + * The shared `OkHttpClient` and the mTLS [HttpTransport] are taken as [dagger.Lazy] and passed as + * SUPPLIERS, so nothing here builds an `SSLSocketFactory` (AndroidKeyStore + Tink reads) while the + * Hilt graph is assembled — that assembly happens on `Main`, and a previous review caught exactly + * that class of ANR. The scheduler resolves both suppliers inside its own `Dispatchers.IO` hop. + * + * `plainTransport` is deliberately NOT passed: its default builds a fresh identity-free client, and + * that separation is the entire point of the recovery path (no TLS terminator will forward an expired + * client certificate, in any `ssl_verify_client` mode). + */ + @Provides + @Singleton + public fun provideCertificateRotationScheduler( + certStore: CertStore, + recordStore: EnrollmentRecordStore, + cacheRefresher: IdentityCacheRefresher, + client: dagger.Lazy, + httpTransport: dagger.Lazy, + ): CertificateRotationScheduler = CertificateRotationScheduler.create( + certStore = certStore, + recordStore = recordStore, + cacheRefresher = cacheRefresher, + sharedClient = { client.get() }, + mtlsTransport = { httpTransport.get() }, + ) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt index 15f6871..cae2871 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt @@ -47,6 +47,7 @@ import wang.yaojia.webterm.viewmodels.CertPhase import wang.yaojia.webterm.viewmodels.CertSummaryView import wang.yaojia.webterm.viewmodels.ClientCertUiState import wang.yaojia.webterm.viewmodels.ClientCertViewModel +import wang.yaojia.webterm.wiring.RotationOutcome /** * # ClientCertScreen (A27) — device-certificate (mTLS) management. @@ -78,6 +79,7 @@ public fun ClientCertScreen( viewModel: ClientCertViewModel, modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, + rotationOutcome: RotationOutcome? = rememberRotationOutcome(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() // Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load. @@ -117,9 +119,21 @@ public fun ClientCertScreen( onDismissError = viewModel::clearError, modifier = modifier, onBack = onBack, + rotationOutcome = rotationOutcome, ) } +/** + * The last silent-rotation outcome, from the app graph. `null` outside a Hilt application (a `@Preview`), + * and `null` before the first pass — both render nothing. + */ +@Composable +internal fun rememberRotationOutcome(): RotationOutcome? { + val outcomes = rememberAppEnvironment()?.rotationOutcomes() ?: return null + val outcome by outcomes.collectAsStateWithLifecycle() + return outcome +} + /** Stateless body — pure inputs so a [Preview] and any host can drive it without the SAF/VM machinery. */ @Composable public fun ClientCertScreen( @@ -132,6 +146,7 @@ public fun ClientCertScreen( onDismissError: () -> Unit, modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, + rotationOutcome: RotationOutcome? = null, ) { Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column( @@ -159,6 +174,10 @@ public fun ClientCertScreen( state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) } + // Silent rotation is invisible by design; a FAILING one must not be. Only the states + // the user can act on are rendered — a healthy/not-due pass stays quiet. + RotationStatusRow(rotationOutcome) + PassphraseField( passphrase = passphrase, onPassphraseChange = onPassphraseChange, @@ -220,6 +239,45 @@ private fun InstalledCertCard(summary: CertSummaryView) { } } +/** + * The last silent-rotation pass, when it is something the user can act on. + * + * Quiet for the healthy states ([RotationOutcome.NotDue] / [RotationOutcome.Renewed] / + * [RotationOutcome.Recovered] / [RotationOutcome.NotEnrolled] / [RotationOutcome.BackingOff]) — a + * successful invisible renewal should stay invisible. Loud only for the three the user must know about: + * a lapsed certificate past its recovery window, a host that has no control-plane URL recorded, and a + * repeatedly failing renewal. + * + * The failure text is the outcome's error SHAPE (a class name or `Http(status,code)`), which is all the + * scheduler retains — deliberately never `Throwable.message`, which can embed a control-plane URL, a + * response body or credential material (plan §8). + */ +@Composable +private fun RotationStatusRow(outcome: RotationOutcome?) { + val message = rotationStatusText(outcome) ?: return + Text(text = message, style = MaterialTheme.typography.bodySmall, color = WebTermColors.statusStuck) +} + +/** App-authored copy for the actionable rotation outcomes; null = say nothing. */ +internal fun rotationStatusText(outcome: RotationOutcome?): String? = when (outcome) { + null, + RotationOutcome.NotEnrolled, + RotationOutcome.Renewed, + RotationOutcome.Recovered, + is RotationOutcome.NotDue, + is RotationOutcome.BackingOff, + -> null + + is RotationOutcome.ReEnrollRequired -> + "⚠ 设备证书已过期超过可恢复期,自动续期无法完成。请用「自动获取证书」重新登记本机。" + + is RotationOutcome.NotConfigured -> + "⚠ 未记录控制面地址,无法自动续期证书。请重新执行一次「自动获取证书」。" + + is RotationOutcome.Failed -> + "⚠ 证书自动续期失败(${outcome.stage.name.lowercase()}:${outcome.errorShape})。当前证书仍然有效。" +} + @Composable private fun SummaryRow(label: String, value: String) { Row( diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt index 14ed4ec..7207f36 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -37,11 +39,16 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.launch import wang.yaojia.webterm.components.PointerContextMenu import wang.yaojia.webterm.components.SessionListRow @@ -51,9 +58,12 @@ import wang.yaojia.webterm.designsystem.Radius import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.nav.LayoutMode import wang.yaojia.webterm.viewmodels.HostOption +import wang.yaojia.webterm.viewmodels.HostRemovalGateway import wang.yaojia.webterm.viewmodels.SessionListUiState import wang.yaojia.webterm.viewmodels.SessionListViewModel import wang.yaojia.webterm.viewmodels.SessionRow +import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore +import wang.yaojia.webterm.wiring.AppEnvironment import java.util.UUID /** @@ -90,6 +100,14 @@ import java.util.UUID * [LayoutMode.STACK] default means a caller that does not pass it gets today's phone behaviour. * @param onNewSessionInCwd pointer-menu「在当前目录开新会话」→ `attach(null, cwd)` in the row's cwd. * `null` (the default) drops that entry. + * @param watermarkStore the DURABLE unread-watermark home, installed into [viewModel] before its first + * fetch so the dot survives process death. Defaults to the app-graph store + * ([rememberAppEnvironment]); a test/preview passes an + * [InMemoryUnreadWatermarkStore][wang.yaojia.webterm.viewmodels.InMemoryUnreadWatermarkStore]. + * @param hostRemoval the un-pair path (`HostRemover`), likewise installed into [viewModel]. `null` + * removes the affordance entirely, which is the right behaviour for a surface that cannot un-pair. + * @param onForeground fired on every STARTED transition — production runs the silent certificate + * rotation pass here (the app's landing surface is the reliable "we are in the foreground" edge). */ @Composable public fun SessionListScreen( @@ -103,14 +121,26 @@ public fun SessionListScreen( thumbnails: SessionThumbnails? = null, layoutMode: LayoutMode = LayoutMode.STACK, onNewSessionInCwd: ((String) -> Unit)? = null, + watermarkStore: UnreadWatermarkStore? = rememberAppEnvironment()?.unreadWatermarkStore, + hostRemoval: HostRemovalGateway? = rememberAppEnvironment()?.hostRemoval, + onForeground: () -> Unit = rememberCertificateRotationTrigger(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val lifecycleOwner = LocalLifecycleOwner.current + var confirmingRemove by remember { mutableStateOf(false) } // STARTED-scoped poll: fetch→wait→repeat while foregrounded, cancelled on background (plan §6.6). - LaunchedEffect(viewModel, lifecycleOwner) { + // The app-scoped collaborators are installed FIRST, inside the same effect, so the install is + // provably ordered before `poll()`'s first ledger read (a separate LaunchedEffect would only be + // ordered by composition order — true today, but not a property worth depending on). + LaunchedEffect(viewModel, lifecycleOwner, watermarkStore, hostRemoval) { + watermarkStore?.let(viewModel::useWatermarkStore) + hostRemoval?.let(viewModel::useRemovalGateway) lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + // Every foreground return: rotate the device certificate if it is due (idempotent, and + // NotDue costs one store read). Fire-and-forget — it must never delay the list. + onForeground() viewModel.poll() } } @@ -125,6 +155,7 @@ public fun SessionListScreen( onPairHost = onPairHost, onEnroll = onEnroll, onImportCert = onImportCert, + onRemoveHost = if (hostRemoval != null) ({ confirmingRemove = true }) else null, ) }, ) { padding -> @@ -142,8 +173,43 @@ public fun SessionListScreen( thumbnails = thumbnails, layoutMode = layoutMode, onNewSessionInCwd = onNewSessionInCwd, + onDismissRemoveError = viewModel::dismissRemoveError, ) } + + if (confirmingRemove) { + RemoveHostConfirmDialog( + hostName = state.hosts.firstOrNull { it.isActive }?.name.orEmpty(), + onConfirm = { + confirmingRemove = false + scope.launch { viewModel.removeActiveHost() } + }, + onDismiss = { confirmingRemove = false }, + ) + } +} + +/** + * Confirm-gate for un-pairing (plan §8): removal deletes this device's push registration ON the host and + * erases the paired record, the last-session pointer, the saved access cookie and the unread watermarks. + * It is not destructive to the host's SESSIONS — they keep running — and the copy says exactly that so + * the user is not guessing. The device certificate is app-wide and deliberately survives. + */ +@Composable +private fun RemoveHostConfirmDialog(hostName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("移除主机") }, + text = { + Text( + // hostName is user-entered at pairing time; rendered inert like every other stored string. + text = "将移除「$hostName」的配对信息、访问凭证与推送注册(该主机不会再向本机推送通知)。" + + "主机上的会话会继续运行;设备证书不会被删除。", + ) + }, + confirmButton = { TextButton(onClick = onConfirm) { Text("移除") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) } // ── Top bar + host menu ────────────────────────────────────────────────────────────────────────────── @@ -156,6 +222,7 @@ private fun SessionListTopBar( onPairHost: () -> Unit, onEnroll: () -> Unit, onImportCert: () -> Unit, + onRemoveHost: (() -> Unit)?, ) { var menuOpen by remember { mutableStateOf(false) } val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话" @@ -175,13 +242,20 @@ private fun SessionListTopBar( onPairHost = { menuOpen = false; onPairHost() }, onEnroll = { menuOpen = false; onEnroll() }, onImportCert = { menuOpen = false; onImportCert() }, + onRemoveHost = onRemoveHost?.let { remove -> { menuOpen = false; remove() } }, ) } }, ) } -/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */ +/** + * The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. + * + * 「移除此主机」acts on the ACTIVE host only — the un-pair has to drop the unread watermarks of that + * host's sessions, and those ids are only known for the host currently listed. It is confirm-gated by the + * caller, and hidden entirely when no removal path is wired. + */ @Composable private fun HostMenu( expanded: Boolean, @@ -191,6 +265,7 @@ private fun HostMenu( onPairHost: () -> Unit, onEnroll: () -> Unit, onImportCert: () -> Unit, + onRemoveHost: (() -> Unit)?, ) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { for (host in hosts) { @@ -206,6 +281,13 @@ private fun HostMenu( // 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS. DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll) DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert) + if (onRemoveHost != null && hosts.any { it.isActive }) { + HorizontalDivider() + DropdownMenuItem( + text = { Text(text = "移除此主机", color = MaterialTheme.colorScheme.error) }, + onClick = onRemoveHost, + ) + } } } @@ -223,33 +305,106 @@ private fun SessionListBody( thumbnails: SessionThumbnails?, layoutMode: LayoutMode, onNewSessionInCwd: ((String) -> Unit)?, + onDismissRemoveError: () -> Unit, ) { androidx.compose.material3.pulltorefresh.PullToRefreshBox( isRefreshing = state.isRefreshing, onRefresh = onRefresh, modifier = Modifier.fillMaxSize().padding(contentPadding), ) { - when { - state.activeHostId == null -> - EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost) - state.rows.isEmpty() -> - EmptyState( - message = if (state.hasLoadError) "无法加载会话列表。下拉重试。" else "该主机没有运行中的会话。", - actionLabel = "开新会话", - onAction = onNewSession, - ) - else -> SessionList( - state = state, - onOpen = onOpen, - onKill = onKill, - thumbnails = thumbnails, - layoutMode = layoutMode, - onNewSessionInCwd = onNewSessionInCwd, - ) + Column(modifier = Modifier.fillMaxSize()) { + // The un-pair failed and NOTHING was removed (all-or-nothing) — say so above the live list. + if (state.hasRemoveError) RemoveErrorBanner(onDismiss = onDismissRemoveError) + Box(modifier = Modifier.weight(1f)) { + when { + state.activeHostId == null -> + EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost) + state.rows.isEmpty() -> + EmptyState( + message = if (state.hasLoadError) { + "无法加载会话列表。下拉重试。" + } else { + "该主机没有运行中的会话。" + }, + actionLabel = "开新会话", + onAction = onNewSession, + ) + else -> SessionList( + state = state, + onOpen = onOpen, + onKill = onKill, + thumbnails = thumbnails, + layoutMode = layoutMode, + onNewSessionInCwd = onNewSessionInCwd, + ) + } + } } } } +/** "nothing was removed" notice — the host is still paired and the action is safe to retry. */ +@Composable +private fun RemoveErrorBanner(onDismiss: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8)) + .padding(Spacing.md12), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), + ) { + Text( + text = "移除主机失败,未做任何更改。可以再试一次。", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismiss) { Text("知道了") } + } +} + +// ── The Hilt escape hatch for app-scoped collaborators (see TerminalScreen's palette store) ───────────── + +/** + * The app-scoped [AppEnvironment], resolved from the Hilt `SingletonComponent` — the standard escape + * hatch for a Composable, which cannot take constructor injection. Returns `null` outside a Hilt + * application (a `@Preview` or a plain-Compose test host), so those surfaces degrade to no durable + * watermarks and no un-pair affordance instead of crashing. + * + * Resolving it is `Main`-safe: every heavy collaborator inside is behind `dagger.Lazy` and the instance + * already exists by the time any screen composes (`MainActivity` field-injects it in `onCreate`). + */ +@Composable +internal fun rememberAppEnvironment(): AppEnvironment? { + val application = LocalContext.current.applicationContext + return remember(application) { + runCatching { + EntryPointAccessors + .fromApplication(application, AppEnvironmentEntryPoint::class.java) + .appEnvironment() + }.getOrNull() + } +} + +/** + * The default `onForeground` action: run one silent certificate-rotation pass if due. A no-op outside a + * Hilt application. Fire-and-forget by construction — [AppEnvironment.runCertificateRotationIfDue] + * launches on its own app-scoped IO scope, so nothing here can delay the session list. + */ +@Composable +internal fun rememberCertificateRotationTrigger(): () -> Unit { + val env = rememberAppEnvironment() + return remember(env) { { env?.runCertificateRotationIfDue() } } +} + +/** Reads the app-scoped [AppEnvironment] for [rememberAppEnvironment]. */ +@EntryPoint +@InstallIn(SingletonComponent::class) +internal interface AppEnvironmentEntryPoint { + public fun appEnvironment(): AppEnvironment +} + @Composable private fun SessionList( state: SessionListUiState, diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt index 918b359..ebc2b50 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt @@ -46,8 +46,11 @@ import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import wang.yaojia.webterm.components.AwayDigestView import wang.yaojia.webterm.components.BannerModel import wang.yaojia.webterm.components.DataStoreQuickReplyStore @@ -55,6 +58,8 @@ import wang.yaojia.webterm.components.GateBanner import wang.yaojia.webterm.components.HardwareKeyRouter import wang.yaojia.webterm.components.KeyBar import wang.yaojia.webterm.components.PlanGateSheet +import wang.yaojia.webterm.components.QueueBadge +import wang.yaojia.webterm.components.QueuePanel import wang.yaojia.webterm.components.QuickReply import wang.yaojia.webterm.components.QuickReplyPalette import wang.yaojia.webterm.components.QuickReplyPaletteEditor @@ -65,10 +70,16 @@ import wang.yaojia.webterm.designsystem.LocalReduceMotion import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.WebTermColors import wang.yaojia.webterm.terminalview.RemoteTerminalView +import wang.yaojia.webterm.terminalview.TerminalCopyResult +import wang.yaojia.webterm.viewmodels.QueueGateway +import wang.yaojia.webterm.viewmodels.QueueUiState +import wang.yaojia.webterm.viewmodels.QueueViewModel import wang.yaojia.webterm.wire.GateKind import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wiring.RetainedSessionHolder import wang.yaojia.webterm.wiring.TerminalSessionControllerImpl +import wang.yaojia.webterm.wiring.UiConfigGate +import java.util.UUID /** * The arguments to open a NEW session — the pure output of the "new session in current cwd" decision. @@ -142,6 +153,10 @@ public object NewSessionInCwd { * ([rememberAppQuickReplyStore] — the SAME `DataStore` singleton every other store uses, so * the palette really persists); a test/preview passes an * [InMemoryQuickReplyStore][wang.yaojia.webterm.components.InMemoryQuickReplyStore]. + * @param queueGateway the W2 follow-up-queue seam for this host. `null` hides the queue affordance + * entirely (a surface with no way to reach the host cannot queue). + * @param uiConfigGate the host's `GET /config/ui` policy. `null` leaves `allowAutoMode` at its + * fail-closed `false`, so "approve + auto-accept" is never offered on an unknown host. */ @Composable public fun TerminalScreen( @@ -154,6 +169,8 @@ public fun TerminalScreen( onWarmUp: suspend () -> Unit = {}, onDigestExpand: () -> Unit = {}, quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(), + queueGateway: QueueGateway? = rememberAppQueueGateway(endpoint), + uiConfigGate: UiConfigGate? = rememberAppEnvironment()?.uiConfigGate, ) { val context = LocalContext.current val activity = remember(context) { context.findActivity() } @@ -243,6 +260,36 @@ public fun TerminalScreen( } } + // `GET /config/ui` → may this host be put into auto-accept-edits? Fetched once per host and pushed + // into the gate presenter, which drops an ACCEPT_EDITS decision while it is false. FAIL CLOSED: a + // failed/absent fetch never calls this, so the presenter keeps its `false` default and the sheet's + // third affordance is not offered at all (see the plan-gate branch below). + LaunchedEffect(gateViewModel, uiConfigGate, endpoint) { + val gate = uiConfigGate ?: return@LaunchedEffect + // Off-Main: the first touch resolves the shared mTLS client (AndroidKeyStore/Tink reads). + val allowed = withContext(Dispatchers.IO) { gate.allowsAutoMode(endpoint) } + if (allowed) gateViewModel.setAutoModeAllowed(true) + } + + // W2 follow-up queue. The presenter is composition-scoped (it holds only HTTP-derived state), while the + // authoritative DEPTH rides the retained gate presenter's control mailbox — so a rotation cannot leak a + // mailbox or lose the badge. A freshly spawned session has no id until the server adopts one. + val queueVm = remember(queueGateway) { queueGateway?.let { QueueViewModel(it) } } + // Remembered UNCONDITIONALLY (a `remember` inside an elvis branch is a conditional composable call) + // so the stand-in for "no queue on this host" is a stable flow, not a per-recomposition instance. + val noQueueState = remember { MutableStateFlow(QueueUiState()) } + val queueUi by (queueVm?.uiState ?: noQueueState).collectAsStateWithLifecycle() + val liveSessionId = remember(sessionId, gateUi.sessionId) { + (sessionId ?: gateUi.sessionId)?.let { raw -> runCatching { UUID.fromString(raw) }.getOrNull() } + } + // The live `queue` frame is the authoritative depth; hand it to the panel presenter so its badge and + // its item list can never disagree with what the server just broadcast. + LaunchedEffect(queueVm, gateUi.queueDepth) { + gateUi.queueDepth?.let { depth -> queueVm?.onServerDepth(depth) } + } + var showQueuePanel by remember { mutableStateOf(false) } + val queueScope = rememberCoroutineScope() + // A25 quick-reply palette: one remembered presenter over the injected store, loaded once per store. val palette = remember(quickReplyStore) { QuickReplyPalette(quickReplyStore) } val quickReplyChips by palette.chips.collectAsStateWithLifecycle() @@ -260,20 +307,62 @@ public fun TerminalScreen( } } + // Copy-out (first-party selection layer in `:terminal-view`). The view handle is captured per + // `generation` so a real-background rebuild never leaves a stale reference behind. + var remoteTerminalView by remember { mutableStateOf(null) } + var isSelecting by remember { mutableStateOf(false) } + var copyNotice by remember { mutableStateOf(null) } + LaunchedEffect(copyNotice) { + if (copyNotice != null) { + delay(COPY_NOTICE_MS) + copyNotice = null + } + } + Column(modifier = modifier.fillMaxSize()) { TerminalToolbar( title = title, onNewSessionInCwd = requestNewSession, onEditQuickReplies = { showPaletteEditor = true }, + queueDepth = gateUi.queueDepth, + onOpenQueue = if (queueVm != null && liveSessionId != null && queueUi.isSupported) { + { + showQueuePanel = true + queueScope.launch { queueVm.refresh(liveSessionId) } + } + } else { + null + }, + // Shown only while a selection is live: an OEM whose floating toolbar misbehaves still has a + // reachable Copy, and the action is ALWAYS an explicit user gesture (§8 — terminal bytes reach + // the clipboard on no other path). + onCopySelection = if (isSelecting) { + { + copyNotice = copyResultText(remoteTerminalView?.copySelection()) + remoteTerminalView?.clearSelection() + } + } else { + null + }, ) + copyNotice?.let { notice -> InlineNotice(text = notice) } ReconnectBanner(model = banner, onNewSession = requestNewSession) // Cockpit surfaces (A22): away-digest (once per reconnect) + statusLine telemetry chips. Both hide // themselves when their state is null/empty; server strings render inert (§8). gateUi.digest?.let { AwayDigestView(digest = it, onExpand = onDigestExpand) } // onExpand → A28 timeline (wired) gateUi.telemetry?.let { TelemetryChips(telemetry = it, nowMs = nowMs) } // Tool gate → an inline approve/reject card; plan gates use the ModalBottomSheet below. + // The W1 `status.preview` MUST be passed: without it the card is a name-only bar and the user is + // approving a `Bash` call without seeing the command (the "blind approval" defect). gateUi.gate?.takeIf { it.kind == GateKind.TOOL }?.let { toolGate -> - GateBanner(gate = toolGate, onDecide = gateViewModel::decide) + GateBanner(gate = toolGate, onDecide = gateViewModel::decide, preview = gateUi.preview) + } + // A plan gate on a host that FORBIDS auto-accept-edits degrades to the same two-way card: only + // 批准 (approve + review) and 拒绝 (keep planning) are real affordances there, so offering the + // three-way sheet would advertise a decision the host will not honour. The authoritative drop + // lives in `GateViewModel.decide`; this is the "don't offer it" half. + gateUi.gate?.takeIf { it.kind == GateKind.PLAN && !gateUi.allowAutoMode }?.let { planGate -> + GateBanner(gate = planGate, onDecide = gateViewModel::decide, preview = gateUi.preview) } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { key(generation) { @@ -297,6 +386,12 @@ public fun TerminalScreen( controller.notifyForegrounded(null, null) } } + // Copy-out: mirror the selection state up so the toolbar can offer a Copy action. + // Stock Termux selection stays unreachable (its ActionMode dereferences the absent + // TerminalSession) — this is the first-party layer, and nothing here calls + // startTextSelectionMode. + remoteView.onSelectionChanged = { selecting -> isSelecting = selecting } + remoteTerminalView = remoteView controller.remoteSession.attachView(remoteView) // The host view IS the View to compose: :terminal-view now exposes its own // `RemoteTerminalHostView` (a FrameLayout that owns focus + the IME contract and @@ -306,7 +401,11 @@ public fun TerminalScreen( }, // Config change / real background: unbind the view but the holder-owned emulator // survives (FIX 3) — never close it here (that is the holder teardown's job). - onRelease = { controller.remoteSession.detachView() }, + onRelease = { + remoteTerminalView = null + isSelecting = false + controller.remoteSession.detachView() + }, ) } if (covered) PrivacyCover() @@ -336,12 +435,70 @@ public fun TerminalScreen( ) } - // Plan gate → a three-way ModalBottomSheet overlay. Dismissing (scrim/back) resolves NOTHING — the - // gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in - // GateViewModel.decide. - gateUi.gate?.takeIf { it.kind == GateKind.PLAN }?.let { planGate -> - PlanGateSheet(gate = planGate, onDecide = gateViewModel::decide, onDismiss = {}) + // Plan gate → a three-way ModalBottomSheet overlay, but ONLY on a host that permits auto-accept-edits + // (`GET /config/ui`); otherwise the two-way card above stands in. Dismissing (scrim/back) resolves + // NOTHING — the gate stays held until an explicit decision (PlanGateSheet contract); the epoch + // stale-guard lives in GateViewModel.decide. + gateUi.gate?.takeIf { it.kind == GateKind.PLAN && gateUi.allowAutoMode }?.let { planGate -> + PlanGateSheet( + gate = planGate, + onDecide = gateViewModel::decide, + onDismiss = {}, + preview = gateUi.preview, + ) } + + // W2 queue panel. Every write is one attempt with a typed outcome — the presenter never auto-retries, + // least of all a 429 (the 20/min bucket is shared with every other client of this host). + if (showQueuePanel && queueVm != null && liveSessionId != null) { + QueuePanel( + state = queueUi, + onSend = { text -> queueScope.launch { queueVm.enqueue(liveSessionId, text) } }, + onClearAll = { queueScope.launch { queueVm.clearAll(liveSessionId) } }, + onDismissNotice = queueVm::dismissNotice, + onDismiss = { showQueuePanel = false }, + onRefresh = { queueScope.launch { queueVm.refresh(liveSessionId) } }, + ) + } +} + +/** How long a copy-outcome notice stays on screen. */ +private const val COPY_NOTICE_MS: Long = 2_000L + +/** + * App-authored copy for a [TerminalCopyResult]. The copied TEXT is never rendered or logged (§8) — only + * whether the copy happened. + */ +internal fun copyResultText(result: TerminalCopyResult?): String = when (result) { + TerminalCopyResult.COPIED -> "已复制到剪贴板" + TerminalCopyResult.NOTHING_SELECTED -> "没有选中内容" + TerminalCopyResult.NOTHING_TO_COPY -> "选中的区域是空白" + TerminalCopyResult.CLIPBOARD_UNAVAILABLE -> "系统剪贴板拒绝了这次复制" + null -> "终端还没准备好" +} + +/** A short-lived inert status line (copy outcome). Fixed app copy — never a server or exception string. */ +@Composable +private fun InlineNotice(text: String) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Spacing.md12, vertical = Spacing.xs4), + ) + } +} + +/** + * The W2 queue seam for [endpoint], resolved from the Hilt graph (a Composable cannot be + * constructor-injected). `null` outside a Hilt application, which correctly hides the affordance in a + * `@Preview`. Remembered per endpoint so a recomposition does not mint a new presenter. + */ +@Composable +internal fun rememberAppQueueGateway(endpoint: HostEndpoint): QueueGateway? { + val env = rememberAppEnvironment() + return remember(env, endpoint) { env?.buildQueueGateway(endpoint) } } /** Telemetry-chip staleness tick interval (ms) — re-reads the wall clock so chips grey as a frame ages. */ @@ -358,6 +515,9 @@ private fun TerminalToolbar( title: String, onNewSessionInCwd: () -> Unit, onEditQuickReplies: () -> Unit, + queueDepth: Int? = null, + onOpenQueue: (() -> Unit)? = null, + onCopySelection: (() -> Unit)? = null, ) { Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { Row( @@ -373,6 +533,14 @@ private fun TerminalToolbar( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + // Only while text is selected — the clipboard write is always an explicit gesture (§8). + onCopySelection?.let { copy -> TextButton(onClick = copy) { Text("复制") } } + onOpenQueue?.let { open -> + TextButton(onClick = open) { + Text("排队") + QueueBadge(depth = queueDepth, modifier = Modifier.padding(start = Spacing.xs4)) + } + } TextButton(onClick = onEditQuickReplies) { Text("快捷回复") } TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt index 3faf88f..7f0f61f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt @@ -10,11 +10,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import wang.yaojia.webterm.session.Adopted import wang.yaojia.webterm.session.AwayDigest import wang.yaojia.webterm.session.Digest import wang.yaojia.webterm.session.Exited import wang.yaojia.webterm.session.Gate import wang.yaojia.webterm.session.GateState +import wang.yaojia.webterm.session.Queued import wang.yaojia.webterm.session.SessionEvent import wang.yaojia.webterm.session.Telemetry import wang.yaojia.webterm.wire.ApprovalPreview @@ -59,6 +61,19 @@ import wang.yaojia.webterm.wiring.TerminalSessionController * mailbox that is INDEPENDENT of the banner's (and the A28 timeline's). Every consumer sees every * control event; none steals from another. Capturing at construction registers the mailbox eagerly, * before the holder calls `controller.start()`, so the first gate is never dropped. + * + * ### `allowAutoMode` fails CLOSED (`GET /config/ui`) + * `approve.mode="acceptEdits"` puts Claude into auto-accept-edits, which an operator can switch off + * host-side (`ALLOW_AUTO_MODE=0` → `GET /config/ui` `{allowAutoMode:false}`). [decide] therefore drops + * [GateDecision.ACCEPT_EDITS] unless [setAutoModeAllowed] has been told the host permits it — the + * default is `false`, so a host that is merely unreachable can never *widen* what the phone may approve. + * This is the authoritative half; the surface additionally stops OFFERING the affordance. + * + * ### It also carries the two cockpit signals nothing else owns + * The `queue` frame ([Queued]) and the adopted session id ([Adopted]) arrive on this same control + * mailbox and used to be discarded here. They live in [GateUiState] because this VM is the retained, + * rotation-surviving cockpit consumer (`RetainedSessionHolder`): a composition-scoped collector would + * both lose them on rotation and leak a fresh orphaned mailbox each time. */ public class GateViewModel( private val controller: TerminalSessionController, @@ -106,12 +121,25 @@ public class GateViewModel( is Gate -> onGate(event.gate) is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry) is Digest -> onDigest(event.digest) - // The session ended: a held gate is no longer decidable — clear it so no stale card lingers. - is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null) - else -> Unit // Output / Connection / Adopted are not cockpit concerns. + // W2: the server's authoritative pending-prompt depth (never a local count). + is Queued -> _uiState.value = _uiState.value.copy(queueDepth = event.length.coerceAtLeast(0)) + // The server-issued id — the only way a freshly spawned session learns what to queue against. + is Adopted -> _uiState.value = _uiState.value.copy(sessionId = event.sessionId) + // The session ended: a held gate is no longer decidable and there is no queue to show. + is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null, queueDepth = null) + else -> Unit // Output / Connection are not cockpit concerns. } } + /** + * Record whether this host permits `approve.mode="acceptEdits"` (`GET /config/ui` + * `{allowAutoMode}`). Called by the screen after a SUCCESSFUL fetch only — a failed fetch must leave + * the fail-closed `false` in place, because the permissive default is the dangerous one. + */ + public fun setAutoModeAllowed(allowed: Boolean) { + _uiState.value = _uiState.value.copy(allowAutoMode = allowed) + } + private fun onGate(gate: GateState?) { // The preview is bound to the gate it describes: it arrives with it and is cleared with it, so // a resolved gate's command/diff can never sit under the NEXT gate's buttons. @@ -136,8 +164,12 @@ public class GateViewModel( * an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message. */ public fun decide(decision: GateDecision, epoch: Int) { - val held = _uiState.value.gate ?: return // canDecide line 1: no gate held → drop. - if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop. + val state = _uiState.value + val held = state.gate ?: return // canDecide line 1: no gate held → drop. + if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop. + // The host's own policy outranks the phone: auto-accept-edits is refused unless a successful + // `GET /config/ui` said it is allowed (fail closed — see the class doc). + if (decision == GateDecision.ACCEPT_EDITS && !state.allowAutoMode) return val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop. controller.decideGate(epoch, message) } @@ -182,6 +214,22 @@ public data class GateUiState( val telemetry: StatusTelemetry? = null, /** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */ val digest: AwayDigest? = null, + /** + * W2 pending prompt-queue depth from the live `queue` frame — `null` = unknown (no frame yet, or the + * session exited), `0` = empty. The badge renders only for a positive value. + */ + val queueDepth: Int? = null, + /** + * The SERVER-issued session id (`attached`/`Adopted`), or `null` before the first attach. The queue + * routes are addressed by it, so a freshly spawned session (opened with a null id) can only be + * queued to once this arrives. + */ + val sessionId: String? = null, + /** + * Does this host permit `approve.mode="acceptEdits"` (`GET /config/ui` `{allowAutoMode}`)? + * **Defaults to `false` and is only ever raised by a SUCCESSFUL fetch** — fail closed. + */ + val allowAutoMode: Boolean = false, ) /** diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/QueueViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/QueueViewModel.kt new file mode 100644 index 0000000..519a1ff --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/QueueViewModel.kt @@ -0,0 +1,269 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import wang.yaojia.webterm.api.models.QueueSnapshot +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.api.routes.ApiClient +import wang.yaojia.webterm.api.routes.ApiClientError +import java.util.UUID + +/** + * # QueueViewModel (W2) — the follow-up prompt queue presenter. + * + * Drives the three server routes (`POST` / `GET` / `DELETE /live-sessions/:id/queue`) behind the + * [QueueGateway] seam: queue a prompt Claude will be handed the next time it goes idle, review what is + * pending, and cancel everything. Ports the web client's `public/queue.ts` affordance. + * + * ### The queued text is raw shell input (invariant #9 / byte-shuttle) + * [enqueue] passes [text] to the gateway **verbatim** — no trim, no normalisation, no case folding, and + * **never a client-side `\r`**. `appendEnter` stays a FLAG so the SERVER materialises the carriage + * return, byte-identical to a keystroke. The only refusal is a genuinely EMPTY string (mirroring + * [ApiClientError.QueueTextEmpty], raised before any I/O); a whitespace-only prompt is legitimate shell + * input and is sent as-is. + * + * ### The depth is always the server's number + * [QueueUiState.depth] is only ever assigned from an authoritative source — a write's + * [QueueWriteOutcome.Ok.length], a [refresh] snapshot, or the live `queue` WS frame ([onServerDepth]). + * Nothing here increments a local counter, so the badge can never drift from the FIFO the server holds. + * + * ### Failure honesty (no auto-retry, ever) + * Each [QueueWriteOutcome] maps to its own [QueueNotice] and the pass STOPS there: + * - [QueueNotice.RateLimited] (429, the shared 20/min/IP bucket) is **shown, never retried** — a retry + * loop only burns the budget a real enqueue needs; + * - [QueueNotice.Disabled] (503, `QUEUE_ENABLED=0`) additionally clears [QueueUiState.isSupported], so + * the affordance disappears for this host instead of failing over and over; + * - [QueueNotice.Full] / [QueueNotice.TooLarge] / [QueueNotice.SessionGone] are actionable states, and + * [QueueNotice.Rejected] carries the server's own already-sanitized `error` string, rendered INERT. + * A transport throw becomes [QueueNotice.Unreachable] and the last-good queue is kept — a network blip + * must not make the panel claim the queue is empty. + * + * ### Testability + * A plain presenter (not an `androidx.lifecycle.ViewModel`) so every branch runs under `runTest` with no + * `Dispatchers.Main`/Robolectric. Its Compose surface ([wang.yaojia.webterm.components.QueuePanel]) is + * device-QA (plan §7). + */ +public class QueueViewModel(private val gateway: QueueGateway) { + + private val _uiState = MutableStateFlow(QueueUiState()) + + /** The single snapshot the queue panel + depth badge render from. */ + public val uiState: StateFlow = _uiState.asStateFlow() + + /** + * `GET /live-sessions/:id/queue` — the pending depth plus the queued prompts (verbatim), for the + * "review before cancelling" panel. Read-only and safe to call on every panel open. + */ + public suspend fun refresh(sessionId: UUID) { + _uiState.update { it.copy(isBusy = true) } + val snapshot = try { + gateway.snapshot(sessionId) + } catch (cancel: CancellationException) { + _uiState.update { it.copy(isBusy = false) } + throw cancel + } catch (error: Throwable) { + _uiState.update { it.copy(isBusy = false, notice = noticeFor(error)) } + return + } + publish(snapshot) + } + + /** + * `POST /live-sessions/:id/queue` — queue [text] VERBATIM. An empty [text] is refused locally (no + * network I/O), matching the server's own 400 and [ApiClientError.QueueTextEmpty]; the size cap is + * deliberately NOT pre-checked here (it is server config the client cannot know) and surfaces as + * [QueueNotice.TooLarge]. + * + * @param appendEnter leave `true` so the SERVER appends the `\r`; never pre-append one. + */ + public suspend fun enqueue(sessionId: UUID, text: String, appendEnter: Boolean = true) { + if (!canSend(text)) return + write { gateway.enqueue(sessionId, text, appendEnter) } + } + + /** + * `DELETE /live-sessions/:id/queue` — cancel EVERY pending entry. There is no per-item cancel on the + * server, so the panel must not pretend to offer one. + */ + public suspend fun clearAll(sessionId: UUID) { + write { gateway.clear(sessionId) } + } + + /** + * The live `queue` WS frame ([wang.yaojia.webterm.session.Queued]) — the server's authoritative + * depth. A positive depth that no longer matches the cached [QueueUiState.items] marks them STALE + * (the panel then re-reads rather than showing a list that contradicts the badge); a zero depth is + * unambiguous and empties the list outright. + */ + public fun onServerDepth(length: Int) { + val depth = length.coerceAtLeast(0) + _uiState.update { state -> + if (depth == 0) { + state.copy(depth = 0, items = emptyList(), isItemsStale = false) + } else { + state.copy(depth = depth, isItemsStale = depth != state.items.size) + } + } + } + + /** Dismiss the current [QueueNotice]; the queue contents and depth are untouched. */ + public fun dismissNotice() { + _uiState.update { it.copy(notice = null) } + } + + // ── Internals ──────────────────────────────────────────────────────────────────────────────── + + /** Shared body of the two guarded writes: busy flag → one attempt → typed outcome → NO retry. */ + private suspend fun write(attempt: suspend () -> QueueWriteOutcome) { + _uiState.update { it.copy(isBusy = true, notice = null) } + val outcome = try { + attempt() + } catch (cancel: CancellationException) { + _uiState.update { it.copy(isBusy = false) } + throw cancel + } catch (error: Throwable) { + _uiState.update { it.copy(isBusy = false, notice = noticeFor(error)) } + return + } + apply(outcome) + } + + /** + * Fold one write outcome into the state. On success the depth is the server's number and the cached + * item list is re-read lazily (marked stale unless the queue is now empty). + */ + private fun apply(outcome: QueueWriteOutcome) { + when (outcome) { + is QueueWriteOutcome.Ok -> { + onServerDepth(outcome.length) + _uiState.update { it.copy(isBusy = false, notice = null) } + } + + QueueWriteOutcome.Full -> fail(QueueNotice.Full) + QueueWriteOutcome.TooLarge -> fail(QueueNotice.TooLarge) + QueueWriteOutcome.SessionGone -> fail(QueueNotice.SessionGone) + QueueWriteOutcome.RateLimited -> fail(QueueNotice.RateLimited) + QueueWriteOutcome.Disabled -> { + // A capability answer, not a transient failure: stop offering the affordance on this host. + _uiState.update { it.copy(isBusy = false, isSupported = false, notice = QueueNotice.Disabled) } + } + + is QueueWriteOutcome.Rejected -> fail(QueueNotice.Rejected(outcome.message)) + } + } + + private fun fail(notice: QueueNotice) { + _uiState.update { it.copy(isBusy = false, notice = notice) } + } + + private fun publish(snapshot: QueueSnapshot) { + _uiState.update { + it.copy( + depth = snapshot.length.coerceAtLeast(0), + items = snapshot.items, + isBusy = false, + isItemsStale = false, + notice = null, + ) + } + } + + /** A thrown failure → its notice. A 404 is a real state; everything else is "could not reach". */ + private fun noticeFor(error: Throwable): QueueNotice = when (error) { + is ApiClientError.SessionNotFound -> QueueNotice.SessionGone + else -> QueueNotice.Unreachable + } + + public companion object { + /** + * May [text] be sent? Only EMPTY is refused — whitespace is legitimate keyboard input for a + * shell, so trimming here would silently change what the user typed (invariant #9). + */ + public fun canSend(text: String): Boolean = text.isNotEmpty() + } +} + +/** The queue panel + badge snapshot. [items] is empty when nothing is queued OR nothing was read yet. */ +public data class QueueUiState( + /** The server's pending depth — the "N queued" badge number. Never locally incremented. */ + val depth: Int = 0, + /** The queued prompts in FIFO order, VERBATIM (rendered inert — untrusted round-tripped text). */ + val items: List = emptyList(), + /** A write/read is in flight (the send + cancel buttons disable). */ + val isBusy: Boolean = false, + /** False after a 503 — the host has `QUEUE_ENABLED=0`, so hide the affordance entirely. */ + val isSupported: Boolean = true, + /** [items] no longer matches [depth] (a live frame moved the queue) — re-read before trusting it. */ + val isItemsStale: Boolean = false, + /** The last failure to report, or null. */ + val notice: QueueNotice? = null, +) + +/** + * What went wrong on the last queue operation. Typed (not a status code) because each one demands + * different UI behaviour; [Rejected] carries the server's own sanitized `error` string, which must be + * rendered INERT (plain text, no linkify/markdown — plan §8). + */ +public sealed interface QueueNotice { + /** 409 — the bounded FIFO is at `QUEUE_MAX_ITEMS`; cancel something first. */ + public data object Full : QueueNotice + + /** 413 — over the server's per-item byte cap. */ + public data object TooLarge : QueueNotice + + /** 503 — the queue is switched off on this host. */ + public data object Disabled : QueueNotice + + /** 404 — the session exited or was killed. */ + public data object SessionGone : QueueNotice + + /** 429 — the shared 20/min/IP bucket. Shown and NEVER auto-retried. */ + public data object RateLimited : QueueNotice + + /** A transport failure / unparseable body — the queue state is unknown, the last-good view is kept. */ + public data object Unreachable : QueueNotice + + /** Any other 4xx/5xx, with the server's inert message (null when the body was empty). */ + public data class Rejected(val message: String?) : QueueNotice +} + +/** + * The three queue routes as a seam, so [QueueViewModel] is JVM-testable with no transport. Production is + * [ApiClientQueueGateway] over the per-host [ApiClient]; tests script outcomes. + */ +public interface QueueGateway { + /** `GET /live-sessions/:id/queue` — depth + the queued prompts. Throws for 404 / a garbled body. */ + public suspend fun snapshot(id: UUID): QueueSnapshot + + /** `POST /live-sessions/:id/queue` — [text] passed through VERBATIM. */ + public suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome + + /** `DELETE /live-sessions/:id/queue` — cancel-all (there is no per-item cancel). */ + public suspend fun clear(id: UUID): QueueWriteOutcome +} + +/** + * Production [QueueGateway] over one host's [ApiClient] (which stamps `Origin` on the two writes). + * + * [api] is a SUPPLIER and every call hops to [ioDispatcher], for the reason `ApiClientFactory` documents: + * resolving the shared `HttpTransport` builds the mTLS `OkHttpClient` (AndroidKeyStore + Tink reads), and + * this gateway is driven from a Composable's `rememberCoroutineScope()` — i.e. from `Main`. Building the + * gateway therefore stays free, and no keystore work can land on the UI thread. + */ +public class ApiClientQueueGateway( + private val api: () -> ApiClient, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : QueueGateway { + override suspend fun snapshot(id: UUID): QueueSnapshot = withContext(ioDispatcher) { api().queue(id) } + + override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome = + withContext(ioDispatcher) { api().enqueueFollowup(id, text, appendEnter) } + + override suspend fun clear(id: UUID): QueueWriteOutcome = withContext(ioDispatcher) { api().clearQueue(id) } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt index 10f741f..92036d2 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt @@ -14,6 +14,7 @@ import wang.yaojia.webterm.api.routes.ApiClientError import wang.yaojia.webterm.designsystem.DisplayStatus import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.hostregistry.SessionWatermarkStore import wang.yaojia.webterm.session.TitleSanitizer import wang.yaojia.webterm.session.UnreadLedger import wang.yaojia.webterm.wire.Tunables @@ -25,9 +26,16 @@ import kotlin.time.Duration * * Folds `GET /live-sessions` for the ACTIVE host into a single [collectAsStateWithLifecycle-friendly] * [uiState] snapshot of [SessionRow]s (status badge · telemetry · thumbnail key · cols×rows · sanitized - * title · unread dot), and owns the four list actions: STARTED-scoped [poll], [refresh] - * (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open) and [killSession] - * (optimistic swipe-to-kill). Ports the iOS session-list surface. + * title · unread dot · W2 queue depth), and owns the list actions: STARTED-scoped [poll], [refresh] + * (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open), [killSession] + * (optimistic swipe-to-kill) and [removeActiveHost] (the confirm-gated un-pair). Ports the iOS + * session-list surface. + * + * ### The unread dot is DURABLE + * Watermarks live behind [UnreadWatermarkStore]; production binds [PersistedUnreadWatermarkStore] over + * `:host-registry`'s DataStore store, so the dot survives process death — exactly when it matters, the + * product premise being "walk away and come back". The reducer stays in `UnreadLedger` (`:session-core`): + * this VM only decides WHERE a watermark lives, never how one is computed. * * ### Untrusted server boundary (plan §4 / §8) * The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` → @@ -45,14 +53,16 @@ import kotlin.time.Duration * ### Testability * A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time with * no `Dispatchers.Main`/Robolectric. Its collaborators are seams ([SessionListGatewayFactory], - * [UnreadWatermarkStore]) so the fetch→row mapping, unread-dot logic and optimistic-kill path are all - * JVM-tested; swipe/thumbnail/pull gestures are device-QA (plan §7). + * [UnreadWatermarkStore], [HostRemovalGateway]) so the fetch→row mapping, unread-dot logic, + * optimistic-kill path and the all-or-nothing un-pair are all JVM-tested; swipe/thumbnail/pull gestures + * are device-QA (plan §7). */ public class SessionListViewModel( private val hostStore: HostStore, private val gatewayFactory: SessionListGatewayFactory, - private val watermarkStore: UnreadWatermarkStore = InMemoryUnreadWatermarkStore(), + watermarkStore: UnreadWatermarkStore? = null, private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL, + removalGateway: HostRemovalGateway? = null, ) { private val _uiState = MutableStateFlow(SessionListUiState()) @@ -68,10 +78,59 @@ public class SessionListViewModel( /** The active host id; sticky across polls, defaults to the first host until the user switches. */ private var activeHostId: String? = null - /** Local unread watermarks (persisted through [watermarkStore]); loaded once, lazily. */ + /** Local unread watermarks (persisted through [watermarks]); loaded once, lazily. */ private var ledger: UnreadLedger = UnreadLedger() private var ledgerLoaded = false + /** + * Where the watermarks live. Defaults to memory-only so a test/preview needs no storage; production + * installs the DataStore-backed [PersistedUnreadWatermarkStore] via [useWatermarkStore]. + */ + private var watermarks: UnreadWatermarkStore = watermarkStore ?: InMemoryUnreadWatermarkStore() + + /** True when the store came from the constructor — [useWatermarkStore] must never override it. */ + private var watermarkStoreInstalled: Boolean = watermarkStore != null + + /** Un-pairing collaborator, installable post-construction for the same reason as [watermarks]. */ + private var removal: HostRemovalGateway? = removalGateway + private var removalInstalled: Boolean = removalGateway != null + + // ── Why the two collaborators above are installable AFTER construction ─────────────────────── + // Both are app-scoped Hilt singletons, but this VM is constructed by the nav layer, which does not + // reach the Hilt graph; the SCREEN does (`SessionListScreen` resolves them through a Hilt + // `@EntryPoint` — the same escape hatch `TerminalScreen` already uses for its palette store). + // Constructor injection remains the preferred wiring and always WINS: passing either one makes the + // matching installer a no-op, so a test (or a future nav-side wiring) is never overridden. + + /** + * Install the DURABLE watermark store, ONCE, before the ledger is first read. + * + * Refused (returning false, changing nothing) when a store is already installed OR the ledger has + * already been loaded — swapping stores mid-flight could resurrect watermarks the live ledger already + * dropped, or silently discard a `markSeen` that was persisted elsewhere. + * + * @return true when [store] became this VM's watermark home. + */ + public fun useWatermarkStore(store: UnreadWatermarkStore): Boolean { + if (watermarkStoreInstalled || ledgerLoaded) return false + watermarks = store + watermarkStoreInstalled = true + return true + } + + /** + * Install the host-removal collaborator, ONCE. Without it [removeActiveHost] is a no-op (which is the + * correct behaviour for a preview/test that has no way to un-pair anything). + * + * @return true when [gateway] became this VM's removal path. + */ + public fun useRemovalGateway(gateway: HostRemovalGateway): Boolean { + if (removalInstalled) return false + removal = gateway + removalInstalled = true + return true + } + /** * STARTED-scoped poll loop (plan §6.6): fetch the active host's list, wait [pollInterval], repeat. * Cancellation (app backgrounded) stops it cleanly. Run inside `repeatOnLifecycle(STARTED)`. @@ -107,7 +166,7 @@ public class SessionListViewModel( val row = _uiState.value.rows.firstOrNull { it.id == sessionId } ?: return val at = row.info.lastOutputAt ?: return ledger = ledger.record(sessionId.toString(), at) - runCatching { watermarkStore.save(ledger) } + runCatching { watermarks.save(ledger) } _uiState.update { it.copy(rows = it.rows.map { r -> r.withUnread(ledger) }) } } @@ -134,6 +193,46 @@ public class SessionListViewModel( } } + /** + * Un-pair the ACTIVE host: hand it (and the session ids currently listed for it) to the + * [HostRemovalGateway], which is the ONE place that erases every trace keyed off that host — + * the push registration on the host itself (`DELETE /push/fcm-token`, otherwise the phone keeps + * receiving notifications for a host the user dropped), the paired record, the last-session pointer, + * the `webterm_auth` cookie, and those sessions' unread watermarks. + * + * Only the ACTIVE host is removable, because the live session ids — and therefore the watermarks to + * drop — are only known for the host currently listed. Switching first is one tap. + * + * ALL-OR-NOTHING at the UI level: a failure leaves the host exactly where it was and raises + * [SessionListUiState.hasRemoveError], because a half-removed host is worse than none. On success the + * list re-fetches, so the active host re-resolves to a survivor (or the empty pair-a-host state). + */ + public suspend fun removeActiveHost() { + val gateway = removal ?: return + val hostId = activeHostId ?: return + if (!hostsById.containsKey(hostId)) return + val sessionIds = _uiState.value.rows.map { it.id.toString() } + + try { + gateway.removeHost(hostId, sessionIds) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + _uiState.update { it.copy(hasRemoveError = true) } + return + } + // Forget the pointer so the next fetch resolves the first SURVIVING host instead of re-selecting + // a host that no longer exists. + activeHostId = null + _uiState.update { it.copy(hasRemoveError = false) } + fetch(markRefreshing = true) + } + + /** Dismiss the removal-failure banner (the host is still paired; nothing else changed). */ + public fun dismissRemoveError() { + _uiState.update { it.copy(hasRemoveError = false) } + } + // ── Internals ──────────────────────────────────────────────────────────────────────────────── private suspend fun fetch(markRefreshing: Boolean) { @@ -202,7 +301,7 @@ public class SessionListViewModel( private suspend fun ensureLedgerLoaded() { if (ledgerLoaded) return - ledger = runCatching { watermarkStore.load() }.getOrDefault(UnreadLedger()) + ledger = runCatching { watermarks.load() }.getOrDefault(UnreadLedger()) ledgerLoaded = true } } @@ -234,6 +333,11 @@ public data class SessionListUiState( val isRefreshing: Boolean = false, /** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */ val hasLoadError: Boolean = false, + /** + * The last host removal failed, so the host is STILL paired (all-or-nothing). The screen shows an + * inline notice; nothing was partially erased. + */ + val hasRemoveError: Boolean = false, ) /** @@ -299,7 +403,7 @@ public interface UnreadWatermarkStore { public suspend fun save(ledger: UnreadLedger) } -/** In-memory [UnreadWatermarkStore] (default). Watermarks live for the process only until DataStore lands. */ +/** In-memory [UnreadWatermarkStore] — the test/preview double. Watermarks live for the process only. */ public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()) : UnreadWatermarkStore { @Volatile private var current: UnreadLedger = initial override suspend fun load(): UnreadLedger = current @@ -307,3 +411,46 @@ public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger() current = ledger } } + +/** + * The PRODUCTION [UnreadWatermarkStore]: the ledger-typed seam bridged onto `:host-registry`'s + * DataStore-backed [SessionWatermarkStore], so the unread dot survives process death (the whole point — + * the product premise is walking away and coming back). + * + * The two halves are deliberately typed differently and this adapter is the ONLY join: + * - [UnreadLedger] (`:session-core`) owns the *reducer* — the monotonic `record`, the `isUnread` + * comparison, the tie-broken cap; + * - [SessionWatermarkStore] (`:host-registry`) owns *storage* — validation of ids at rest, the growth + * cap, the JSON blob. + * `:host-registry` may not depend on `:session-core` (nothing points sideways), so neither restates the + * other; `:app` is the one module that sees both. Nothing about the reducer is reimplemented here. + * + * The store applies its own sanitize+cap pass, so [load] can legitimately return FEWER watermarks than + * [save] was given (an invalid id at rest is dropped). That is the correct behaviour, not a bug: the + * ledger simply treats a dropped session as never-seen. + */ +public class PersistedUnreadWatermarkStore( + private val store: SessionWatermarkStore, +) : UnreadWatermarkStore { + override suspend fun load(): UnreadLedger = UnreadLedger(store.loadAll()) + + override suspend fun save(ledger: UnreadLedger) { + store.replaceAll(ledger.watermarks) + } +} + +/** + * Un-pairing a host, as ONE operation (plan §8 — a half-removed host is worse than none). + * + * A single seam rather than five calls from the ViewModel, because the steps span four modules + * (`:api-client` push DELETE, `:host-registry` host/last-session/cookie/watermark stores, + * `:transport-okhttp`'s live cookie jar) and the VM must stay JVM-testable with no transport. + * Production is `HostRemover` in the composition root. + * + * @param sessionIds the host's currently-known live session ids, so their unread watermarks are dropped + * too (watermarks are keyed by SESSION, not host — see `HostRemover`). + */ +public fun interface HostRemovalGateway { + /** Erase every trace of [hostId]. Throws if anything essential failed (the UI then keeps the host). */ + public suspend fun removeHost(hostId: String, sessionIds: List) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt index b2ffeb5..e1fdbd9 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt @@ -1,20 +1,37 @@ package wang.yaojia.webterm.wiring +import android.util.Log import dagger.Lazy import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import wang.yaojia.webterm.api.models.UiConfig import wang.yaojia.webterm.api.pairing.PairingProbeResult import wang.yaojia.webterm.api.pairing.runPairingProbe import wang.yaojia.webterm.hostregistry.AuthCookieRecord import wang.yaojia.webterm.hostregistry.AuthCookieStore +import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.LastSessionStore +import wang.yaojia.webterm.hostregistry.SessionWatermarkStore import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.transport.AuthCookieJar import wang.yaojia.webterm.transport.AuthCookieSnapshot +import wang.yaojia.webterm.transport.authCookieHostKey +import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway +import wang.yaojia.webterm.viewmodels.HostRemovalGateway import wang.yaojia.webterm.viewmodels.PairingProber +import wang.yaojia.webterm.viewmodels.PersistedUnreadWatermarkStore +import wang.yaojia.webterm.viewmodels.QueueGateway import wang.yaojia.webterm.viewmodels.TokenSubmission +import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpRequest @@ -53,6 +70,10 @@ import javax.inject.Singleton * transports (lazy). * - [coldStartPolicy] — the cold-launch route seam (A29). * - the `webterm_auth` cookie jar + its durable store — the optional `WEBTERM_TOKEN` gate. + * - [unreadWatermarkStore] — the DURABLE unread-dot watermarks (was memory-only; reset on process death). + * - [uiConfigGate] — the host's `allowAutoMode` policy, fail-closed (`GET /config/ui`). + * - [hostRemoval] — un-pair a host and erase every trace keyed off it, push registration included. + * - [runCertificateRotationIfDue] / [rotationOutcomes] — silent device-certificate rotation. * Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its * [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder]. */ @@ -81,6 +102,20 @@ public class AppEnvironment @Inject constructor( * under JVM unit tests). */ private val thumbnailRasterizerLazy: Lazy, + /** + * The durable, DataStore-backed home of the unread watermarks (`di/StorageModule`). Exposed to the + * session list through [unreadWatermarkStore]; without it the unread dot resets on process death. + */ + private val sessionWatermarkStore: SessionWatermarkStore, + /** + * Silent device-certificate rotation (`di/TlsModule`). `Lazy` for the same reason as the transports: + * resolving it must not pull AndroidKeyStore/Tink work onto the injection (often `Main`) thread. + */ + private val rotationSchedulerLazy: Lazy, + /** Per-host push registration — the `DELETE /push/fcm-token` half is what [removeHost] finally calls. */ + private val pushUnregister: HostPushUnregister, + /** Resolves this device's current FCM token, or null when push is unavailable/uninitialised. */ + private val pushTokenSource: PushTokenSource, ) { /** One-shot cold-start restore of the persisted session cookie into the live jar (see [warmUp]). */ private val authCookieHydrator = AuthCookieHydrator( @@ -142,6 +177,94 @@ public class AppEnvironment @Inject constructor( rasterizer = thumbnailRasterizerLazy.get(), ) + // ── Unread watermarks (durable) ─────────────────────────────────────────────────────────────── + + /** + * The PRODUCTION unread-watermark store: DataStore-backed, so the session-list dot survives process + * death. The session list installs it via `SessionListViewModel.useWatermarkStore`. + */ + public val unreadWatermarkStore: UnreadWatermarkStore = + PersistedUnreadWatermarkStore(sessionWatermarkStore) + + // ── W2 prompt queue ────────────────────────────────────────────────────────────────────────── + + /** + * The follow-up-queue seam for one host (`POST|GET|DELETE /live-sessions/:id/queue`). Per-host + * because the two writes are `Origin`-guarded and the header is derived from the endpoint. + * + * Cheap + `Main`-safe to build: the `ApiClient` is minted by a SUPPLIER inside the gateway's own + * `Dispatchers.IO` hop, so resolving the shared mTLS `OkHttpClient` never happens on the UI thread + * (the same discipline `sessionThumbnailPipeline` follows). + */ + public fun buildQueueGateway(endpoint: HostEndpoint): QueueGateway = + ApiClientQueueGateway(api = { apiClientFactory.create(endpoint) }) + + // ── GET /config/ui (allowAutoMode) ─────────────────────────────────────────────────────────── + + /** + * The host-policy gate for `approve.mode="acceptEdits"`. One instance for the app so a host is asked + * once per process; it FAILS CLOSED, so an unreachable host never widens what the phone may approve. + */ + public val uiConfigGate: UiConfigGate = UiConfigGate { endpoint -> + apiClientFactory.create(endpoint).uiConfig() + } + + // ── Host removal ───────────────────────────────────────────────────────────────────────────── + + /** + * Un-pair a host and erase EVERY trace keyed off it — including the `DELETE /push/fcm-token` that had + * no caller at all, which is why a removed host kept pushing notifications to this device. See + * [HostRemover] for the ordering rules. + */ + public val hostRemoval: HostRemovalGateway by lazy { + // `by lazy` because the session-list screen reads this on `Main`. Resolving the auth-cookie + // store here is safe by that module's documented contract — `TinkAuthCookieCipher` defers ALL + // AndroidKeyStore/AEAD work to first use — and in practice `warmUp()` has already resolved it + // off-`Main`. Nothing in this block does I/O; the removal itself runs in the caller's coroutine. + HostRemover( + hostStore = hostStore, + lastSessionStore = lastSessionStore, + authCookieStore = authCookieStoreLazy.get(), + watermarkStore = sessionWatermarkStore, + pushUnregister = pushUnregister, + currentPushToken = { pushTokenSource.currentToken() }, + clearLiveCookies = { hostKey -> authCookieJarLazy.get().clear(hostKey) }, + ) + } + + // ── Silent certificate rotation ────────────────────────────────────────────────────────────── + + /** + * App-lifetime scope for the fire-and-forget rotation pass. Deliberately NOT the caller's scope: + * [warmUp] is awaited by the terminal screen's readiness gate, and a renewal's network round-trip + * must never be able to hold the UI behind a spinner. + */ + private val rotationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val rotationTrigger = CertificateRotationTrigger( + scope = rotationScope, + scheduler = { rotationSchedulerLazy.get() }, + onError = { error -> + // Shape only: a rotation error can carry a control-plane URL or response body. + Log.w(ROTATION_TAG, "certificate rotation could not start (${error::class.simpleName})") + }, + ) + + /** + * Run one silent certificate-rotation pass if one is due (the Android counterpart of iOS + * `AppCoordinator.runCertificateRotationIfDue()`). Safe on launch AND on every foreground: the + * scheduler is idempotent, self-coalescing, and `NotDue` costs one store read. Returns immediately. + */ + public fun runCertificateRotationIfDue() { + rotationTrigger.fire() + } + + /** + * The most recent rotation pass's outcome (null before the first pass). Observed by the device-cert + * screen so a silently failing renewal becomes visible instead of only a log line. + */ + public fun rotationOutcomes(): StateFlow = rotationSchedulerLazy.get().lastOutcome + /** * Build the shared `OkHttpClient`, rehydrate the persisted session cookie and first-touch the device * identity OFF `Main` (FIX 3). Resolving either transport builds the one shared client (which reads @@ -159,6 +282,184 @@ public class AppEnvironment @Inject constructor( apiClientFactory.warm() // HttpTransport reuses the SAME already-built client runCatching { identityRepositoryLazy.get().hasInstalledIdentity() } // first AndroidKeyStore/Tink touch } + // Rotate the device certificate if it is due. Fired LAST and fire-and-forget: it needs the warmed + // mTLS stack, and its network round-trip must not be awaited by this function's callers (the + // terminal screen blocks its readiness gate on warmUp). The AndroidKeyStore/Tink reads happen on + // the scheduler's own IO dispatcher, never on `Main`. + runCertificateRotationIfDue() + } + + private companion object { + const val ROTATION_TAG: String = "WebTermRotation" + } +} + +// ── GET /config/ui — the auto-mode host policy ──────────────────────────────────────────────────────── + +/** + * Whether a host permits `approve.mode="acceptEdits"` (`GET /config/ui` → `{ allowAutoMode }`). + * + * `ApiClient.uiConfig()` existed with no caller, so the plan-gate three-way sheet offered + * "approve + auto-accept edits" even on a host whose operator had set `ALLOW_AUTO_MODE=0`. This is the + * client half of that policy, and it **fails CLOSED**: + * - a transport failure, a pre-`/config/ui` server, or an unparseable body all answer `false`; + * - only a SUCCESSFUL fetch is cached, so a host that was merely down is re-asked rather than being + * permanently marked one way or the other; + * - `CancellationException` propagates — a cancelled scope must never be read as a policy answer. + * + * The read-only `GET` carries no `Origin` header (plan §4.3) and no credential of its own; the answer is + * a single boolean, so nothing here has to be redacted. + */ +public class UiConfigGate(private val fetch: suspend (HostEndpoint) -> UiConfig) { + private val mutex = Mutex() + private var cache: Map = emptyMap() + + /** `true` only when [endpoint] positively confirmed it. Never throws except for cancellation. */ + public suspend fun allowsAutoMode(endpoint: HostEndpoint): Boolean { + mutex.withLock { cache[endpoint.baseUrl] }?.let { return it } + val allowed = try { + fetch(endpoint).allowAutoMode + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + return false // FAIL CLOSED — and deliberately NOT cached, so a recovered host is re-asked. + } + mutex.withLock { cache = cache + (endpoint.baseUrl to allowed) } + return allowed + } +} + +// ── Silent certificate rotation: the app-lifecycle trigger ──────────────────────────────────────────── + +/** + * The lifecycle edge that CALLS [CertificateRotationScheduler] — the Android counterpart of iOS + * `AppCoordinator.runCertificateRotationIfDue()`. Without a trigger the scheduler is dead code and a + * device certificate simply expires, stranding the app behind an mTLS handshake it can no longer make. + * + * [fire] launches and RETURNS: the pass does AndroidKeyStore/Tink reads and possibly a control-plane + * round-trip, and no UI path may wait on that. The scheduler is idempotent and self-coalescing, so + * firing on launch and again on every foreground needs no external guard. + * + * Nothing escapes: the scheduler turns a rotation failure into `RotationOutcome.Failed` itself, and + * [onError] only sees the (unexpected) case where the scheduler could not even be resolved. Both report + * the error's SHAPE only — a rotation error can embed a control-plane URL or a response body. + */ +public class CertificateRotationTrigger( + private val scope: CoroutineScope, + private val scheduler: () -> CertificateRotationScheduler, + private val onError: (Throwable) -> Unit = {}, +) { + /** Start a rotation pass in the background. Idempotent by way of the scheduler's single-flight. */ + public fun fire() { + scope.launch { + try { + scheduler().runIfDue() + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + onError(error) + } + } + } +} + +// ── Host removal ────────────────────────────────────────────────────────────────────────────────────── + +/** + * The `DELETE /push/fcm-token` half of [wang.yaojia.webterm.push.PushRegistrar], as a seam so + * [HostRemover] is JVM-testable with no transport. `di/PushModule` binds it to + * `PushRegistrar.unregisterHost` — whose FIRST caller this is. + */ +public fun interface HostPushUnregister { + /** Best-effort: tell [host] to forget [token]. [token] is never logged (plan §8). */ + public suspend fun unregister(host: Host, token: String) +} + +/** This device's current FCM registration token, or null when push is unavailable. */ +public fun interface PushTokenSource { + public suspend fun currentToken(): String? +} + +/** + * Un-pairing a host, as ONE ordered operation. A half-removed host is worse than none, so both the order + * and the failure policy are load-bearing: + * + * 1. **`DELETE /push/fcm-token` FIRST**, while the host record and its `webterm_auth` cookie still + * exist — the call cannot be addressed without the endpoint, nor authenticated without the cookie. + * `PushRegistrar.unregisterHost` had ZERO call sites before this, which is why a dropped host kept + * pushing notifications to the device forever. It is **best-effort**: a host that is switched off + * must still be removable, and the local state is what the user asked us to forget. + * 2. **Then the local state**, in the order that keeps a failure retryable: the session watermarks, the + * last-session pointer, the persisted + live `webterm_auth` cookie, and the paired record LAST. Any + * throw propagates with the host still paired, so the user can simply try again — the UI treats that + * as "nothing was removed" ([HostRemovalGateway]). + * + * ### What is deliberately NOT removed + * - **The device certificate / AndroidKeyStore identity** — it is ONE app-wide mTLS identity shared by + * every host (`IdentityRepository`), not per-host state. Removing it while un-pairing one host would + * silently break every other tunnel host. It has its own explicit affordance on the cert screen. + * - **The quick-reply palette** — global user content, not host state. + * + * ### Watermarks are keyed by SESSION, not host + * `SessionWatermarkStore` maps `sessionId → last-seen`, so there is no host-scoped sweep to run. The + * caller passes the ids it can attribute to this host (its currently-listed sessions); anything it could + * not see is bounded by the store's own 512-entry cap rather than leaking without limit. + */ +public class HostRemover( + private val hostStore: HostStore, + private val lastSessionStore: LastSessionStore, + private val authCookieStore: AuthCookieStore, + private val watermarkStore: SessionWatermarkStore, + private val pushUnregister: HostPushUnregister, + private val currentPushToken: suspend () -> String?, + private val clearLiveCookies: (hostKey: String) -> Unit, + private val log: (String) -> Unit = { Log.w(REMOVER_TAG, it) }, + /** Everything below is storage + network I/O; the caller is a Composable scope on `Main`. */ + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : HostRemovalGateway { + + override suspend fun removeHost(hostId: String, sessionIds: List): Unit = + withContext(ioDispatcher) { + val host = hostStore.loadAll().firstOrNull { it.id == hostId } + ?: return@withContext // already gone → no-op + + unregisterPushBestEffort(host) + + // Local erasure. Ordered so `hostStore.remove` is LAST: an earlier failure leaves the host + // paired, which the UI reports as "nothing removed" and the user can retry. + sessionIds.forEach { watermarkStore.remove(it) } + lastSessionStore.setLastSessionId(null, hostId) + authCookieHostKey(host.endpoint.baseUrl)?.let { hostKey -> + authCookieStore.removeHost(hostKey) + clearLiveCookies(hostKey) // the in-memory jar too, or the credential rides the next dial + } + hostStore.remove(hostId) + } + + /** + * Tell the host to forget this device's push token. Best-effort by design; the token is passed only + * to the call and never logged (plan §8), and a failure is reported by SHAPE. + */ + private suspend fun unregisterPushBestEffort(host: Host) { + val token = try { + currentPushToken() + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + log("push token unavailable while removing a host (${error::class.simpleName})") + null + } ?: return + try { + pushUnregister.unregister(host, token) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + log("push de-registration failed for the removed host (${error::class.simpleName})") + } + } + + private companion object { + const val REMOVER_TAG: String = "WebTermHostRemoval" } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/CertificateRotationScheduler.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/CertificateRotationScheduler.kt new file mode 100644 index 0000000..695c0aa --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/CertificateRotationScheduler.kt @@ -0,0 +1,328 @@ +package wang.yaojia.webterm.wiring + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.api.enroll.RotationDecision +import wang.yaojia.webterm.api.enroll.RotationPolicy +import wang.yaojia.webterm.tlsandroid.CertStore +import wang.yaojia.webterm.tlsandroid.DeviceEnroller +import wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot +import wang.yaojia.webterm.tlsandroid.DeviceRotationStore +import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore +import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher +import wang.yaojia.webterm.transport.OkHttpClientFactory +import wang.yaojia.webterm.transport.OkHttpHttpTransport +import wang.yaojia.webterm.wire.HttpTransport +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference + +/** Which step of a rotation pass an outcome refers to — so a failure names what actually failed. */ +public enum class RotationStage { + /** Reading the installed identity's timing out of the encrypted stores. */ + READ_STATE, + + /** `POST /device/:id/renew` over mTLS with the current (still valid) certificate. */ + RENEW, + + /** `POST /device/:id/recover` over plain HTTPS with the lapsed certificate in the body. */ + RECOVER, +} + +/** + * The total result of one rotation pass. Every case is safe to log and to render in the UI: none of + * them can carry a private key, a certificate, a CSR or a token (see [RotationOutcome.Failed]). + */ +public sealed interface RotationOutcome { + /** No enrolled device identity (fresh install / legacy `.p12`): nothing to rotate. */ + public data object NotEnrolled : RotationOutcome + + /** + * Enrolled, but no control-plane URL is persisted (a record written before it was recorded), so + * there is no host to rotate against. Surfaced rather than guessed — a compiled-in default would + * silently talk to the wrong control plane. A re-enroll records the URL and clears this. + */ + public data class NotConfigured(val deviceId: String) : RotationOutcome + + /** The renew window has not opened yet — the cheap, common case. */ + public data class NotDue(val renewAfter: Instant?) : RotationOutcome + + /** An attempt is due but the previous one failed too recently; the next try is at [retryAt]. */ + public data class BackingOff(val retryAt: Instant) : RotationOutcome + + /** Renewed silently; the new leaf is presented on the next handshake. */ + public data object Renewed : RotationOutcome + + /** An expired leaf was recovered over plain HTTPS; the new leaf is live. */ + public data object Recovered : RotationOutcome + + /** + * TERMINAL — the leaf expired [expiredFor] ago, past the recovery grace. No request can succeed; + * only a fresh enroll can. Reported once per pass and never retried, so one real failure cannot + * become thousands of identical warnings. + */ + public data class ReEnrollRequired(val expiredFor: Duration) : RotationOutcome + + /** + * The pass failed at [stage]. The PRIOR identity is fully live and unchanged (the atomic + * live-pointer flip only happens after a successful re-issuance), so a transient failure is + * recoverable on a later pass. + * + * [errorShape] is deliberately the error's SHAPE — its class name, or `Http(status,code)` for a + * server rejection — and never `Throwable.message`, which can embed a URL, a response body or + * credential material. + */ + public data class Failed(val stage: RotationStage, val errorShape: String) : RotationOutcome +} + +/** + * Silent device-certificate rotation: on a trigger (app launch / foreground) read the installed + * identity's timing, ask the pure [RotationPolicy] what to do, and drive it. This is the + * "one bootstrap enroll, then never again" half of zero-touch — the Android counterpart of iOS + * `ClientTLS/CertificateRotationScheduler.swift`, which Android was missing entirely (so a device + * certificate simply expired and stranded the app). + * + * It goes beyond the iOS version in one way that matters: iOS can only renew, and `/device/:id/renew` + * is authenticated by the very certificate it renews — so an iOS device whose leaf lapses is stuck + * needing a manual re-enroll. This driver routes a lapsed leaf to `/device/:id/recover` instead + * ([RotationStage.RECOVER]), which takes the expired certificate in the request BODY over PLAIN + * HTTPS because no TLS terminator will forward an expired client certificate at all. + * + * ### The one place allowed to know both halves + * `:api-client` must never gain a `:client-tls-android` edge. This file is the composition point that + * knows both: the pure policy/HTTP client on one side, the keystore-backed enroller on the other. + * + * ### Idempotent, single-flight, and honest + * - Safe to call on EVERY foreground: `NotDue` is the cheap common case and costs no I/O beyond one + * store read. + * - A second trigger while a pass is in flight COALESCES onto it (an `AtomicReference` holding the + * in-flight result), so two foregrounds cannot stampede two renewals into a control plane that + * rate-limits them. + * - A failure never throws out of [runIfDue]: it is recorded as [RotationOutcome.Failed], published + * on [lastOutcome], and used to throttle the next pass. The prior identity stays fully live — + * `IdentityRepository`'s atomic live-pointer flip guarantees that, and nothing here weakens it. + * - A cancelled pass releases the in-flight slot, so cancellation cannot wedge rotation for the + * lifetime of the process. + * + * ### Threading + * The class itself is dispatcher-free (so every branch is unit-testable under virtual time); the + * store/network hop is inside the seams [create] builds, which move the AndroidKeyStore + Tink reads + * off `Dispatchers.Main`. + * + * ### Step-up policy does NOT apply here + * The relay's step-up gate lives in `relay-auth`'s `enforce/onUpgrade.ts` — the terminal-session + * WebSocket upgrade. Renewal and recovery are plain control-plane HTTPS routes and consult no + * step-up policy, so a step-up-required host does not block certificate rotation. + */ +public class CertificateRotationScheduler( + /** Current rotation timing + target, or null when nothing is enrolled. May throw on a store fault. */ + private val readState: suspend () -> DeviceRotationSnapshot?, + /** `POST /device/:id/renew` over mTLS against the given control-plane base URL. */ + private val renew: suspend (controlPlaneUrl: String) -> Unit, + /** `POST /device/:id/recover` over PLAIN HTTPS against the given control-plane base URL. */ + private val recover: suspend (controlPlaneUrl: String) -> Unit, + private val now: () -> Instant = { Instant.now() }, + private val expiredRecoveryGrace: Duration = RotationPolicy.DEFAULT_EXPIRED_RECOVERY_GRACE, + private val failureBackoff: Duration = RotationPolicy.DEFAULT_FAILURE_BACKOFF, + private val log: (String) -> Unit = { Log.i(TAG, it) }, +) { + private val outcomes = MutableStateFlow(null) + + /** + * The most recent pass's outcome, or null before the first pass. Observable so a silently failing + * renewal becomes visible state (iOS surfaces the same as `isCertificateRenewalFailing`) instead + * of only a log line. + */ + public val lastOutcome: StateFlow = outcomes.asStateFlow() + + /** Written only by the in-flight pass (single-flight); volatile so any dispatcher sees it. */ + @Volatile + private var lastFailureAt: Instant? = null + + /** Non-null while a pass is running — the shared result concurrent triggers coalesce onto. */ + private val inFlight = AtomicReference?>(null) + + /** + * Run one pass: read timing → decide → renew/recover if due. Idempotent and safe on every + * foreground; never throws for a rotation failure (that is [RotationOutcome.Failed]). Only + * cancellation propagates. + */ + public suspend fun runIfDue(): RotationOutcome { + while (true) { + inFlight.get()?.let { return it.await() } + val pass = CompletableDeferred() + if (!inFlight.compareAndSet(null, pass)) continue // lost the race — join the winner + try { + val outcome = runPass() + outcomes.value = outcome + inFlight.set(null) // release BEFORE completing so a later trigger starts a fresh pass + pass.complete(outcome) + return outcome + } catch (throwable: Throwable) { + // Cancellation (the only thing that reaches here) must not leave a slot that no + // future pass can ever get past. + inFlight.set(null) + pass.completeExceptionally(throwable) + throw throwable + } + } + } + + private suspend fun runPass(): RotationOutcome { + val snapshot = try { + readState() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + return recordFailure(RotationStage.READ_STATE, error) + } ?: return RotationOutcome.NotEnrolled + + val state = snapshot.renewalState + val at = now() + val failedAt = lastFailureAt + val decision = RotationPolicy.decide( + state = state, + now = at, + lastFailureAt = failedAt, + expiredRecoveryGrace = expiredRecoveryGrace, + failureBackoff = failureBackoff, + ) + return when (decision) { + RotationDecision.NOT_DUE -> RotationOutcome.NotDue(state.renewAfter) + RotationDecision.BACKING_OFF -> + // The policy only answers BACKING_OFF when a failure was recorded, so `failedAt` is + // present; fall back to `at` rather than assert, so a logic change cannot crash here. + RotationOutcome.BackingOff((failedAt ?: at).plus(failureBackoff)) + RotationDecision.RENEW -> + attempt(RotationStage.RENEW, snapshot, renew, RotationOutcome.Renewed) + RotationDecision.RECOVER -> + attempt(RotationStage.RECOVER, snapshot, recover, RotationOutcome.Recovered) + RotationDecision.RE_ENROLL_REQUIRED -> reEnrollRequired(snapshot, at) + } + } + + /** + * Run one re-issuance [operation] against the persisted control plane. The operation is passed in + * rather than selected from [stage] so there is no unreachable "stage that is not an attempt" + * branch to get wrong. + */ + private suspend fun attempt( + stage: RotationStage, + snapshot: DeviceRotationSnapshot, + operation: suspend (controlPlaneUrl: String) -> Unit, + success: RotationOutcome, + ): RotationOutcome { + val deviceId = snapshot.renewalState.deviceId + val controlPlaneUrl = snapshot.controlPlaneUrl.trim() + if (controlPlaneUrl.isEmpty()) { + log("rotation: device $deviceId has no persisted control-plane URL; re-enroll to record it") + return RotationOutcome.NotConfigured(deviceId) + } + return try { + operation(controlPlaneUrl) + lastFailureAt = null + log("rotation: device $deviceId certificate ${stage.name.lowercase()} succeeded") + success + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + recordFailure(stage, error) + } + } + + private fun reEnrollRequired(snapshot: DeviceRotationSnapshot, at: Instant): RotationOutcome { + val notAfter = snapshot.renewalState.notAfter + val expiredFor = if (notAfter == null) Duration.ZERO else Duration.between(notAfter, at) + log( + "rotation: device ${snapshot.renewalState.deviceId} certificate expired ${expiredFor.toDays()}d ago, " + + "past the ${expiredRecoveryGrace.toDays()}d recovery window; a fresh enroll is required", + ) + return RotationOutcome.ReEnrollRequired(expiredFor) + } + + /** + * Record + report a failure. The prior identity is untouched, so the next pass can succeed. Only + * the error's SHAPE is kept — never `message`, which can embed a URL, a body or a credential. + */ + private fun recordFailure(stage: RotationStage, error: Exception): RotationOutcome { + lastFailureAt = now() + val shape = errorShapeOf(error) + log("rotation: ${stage.name.lowercase()} failed ($shape); the current certificate stays live") + return RotationOutcome.Failed(stage, shape) + } + + public companion object { + private const val TAG = "CertRotation" + + /** + * The error's non-secret shape. A server rejection keeps its status + uniform `error` code + * (401 = expired beyond grace / untrusted, 403 = revoked or mismatched, 429 = rate limited), + * which is exactly the diagnostic value needed; everything else degrades to the class name. + */ + private fun errorShapeOf(error: Exception): String = when (error) { + is DeviceEnrollmentError.Http -> + "Http(${error.status}${error.code?.let { ",$it" } ?: ""})" + else -> error.javaClass.simpleName + } + + /** + * Production wiring — the single composition point, so a DI module needs one provider. + * + * [mtlsTransport] is the app's shared REST transport, which presents the current device + * certificate; [plainTransport] builds a SEPARATE client with NO client identity, used only by + * the recovery path. That separation is load-bearing: an expired leaf presented on a handshake + * makes nginx answer a bare 400 (it will not forward an expired client cert in any + * `ssl_verify_client` mode), which is the deadlock recovery exists to escape. The plain client + * is built lazily, so the common (renew) path never pays for it. + * + * Store reads and both HTTP calls are hopped onto [ioDispatcher] — the first identity touch + * does synchronous AndroidKeyStore + Tink work and must never run on `Dispatchers.Main`. + * + * [sharedClient] / [mtlsTransport] / [plainTransport] are SUPPLIERS, not values, for the same + * reason [EnrollmentFlowFactory] takes `dagger.Lazy`: resolving the shared client builds the + * mTLS `SSLSocketFactory` (keystore I/O). As suppliers they are invoked only inside the + * [ioDispatcher] hop, so a DI provider for this scheduler is itself I/O-free and can be + * resolved from the main thread without risking an ANR. + */ + public fun create( + certStore: CertStore, + recordStore: EnrollmentRecordStore, + cacheRefresher: IdentityCacheRefresher, + sharedClient: () -> OkHttpClient, + mtlsTransport: () -> HttpTransport, + plainTransport: () -> HttpTransport = { OkHttpHttpTransport(OkHttpClientFactory.create()) }, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + now: () -> Instant = { Instant.now() }, + log: (String) -> Unit = { Log.i(TAG, it) }, + ): CertificateRotationScheduler { + val rotationStore = DeviceRotationStore(certStore, recordStore) + fun enroller(controlPlaneUrl: String, withRecovery: Boolean): DeviceEnroller = + DeviceEnroller( + client = DeviceEnrollmentClient(controlPlaneUrl, mtlsTransport()), + certStore = certStore, + recordStore = recordStore, + sharedClient = sharedClient(), + cacheRefresher = cacheRefresher, + recoveryClient = + if (withRecovery) DeviceEnrollmentClient(controlPlaneUrl, plainTransport()) else null, + ) + return CertificateRotationScheduler( + readState = { withContext(ioDispatcher) { rotationStore.read() } }, + renew = { url -> withContext(ioDispatcher) { enroller(url, withRecovery = false).renew() } }, + recover = { url -> withContext(ioDispatcher) { enroller(url, withRecovery = true).recover() } }, + now = now, + log = log, + ) + } + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateAutoModeTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateAutoModeTest.kt new file mode 100644 index 0000000..2453db6 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateAutoModeTest.kt @@ -0,0 +1,184 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.session.Adopted +import wang.yaojia.webterm.session.Exited +import wang.yaojia.webterm.session.Gate +import wang.yaojia.webterm.session.GateState +import wang.yaojia.webterm.session.Queued +import wang.yaojia.webterm.session.SessionEvent +import wang.yaojia.webterm.wire.ApproveMode +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.GateKind +import wang.yaojia.webterm.wiring.TerminalSessionController + +/** + * [GateViewModel] — the `GET /config/ui` `allowAutoMode` gate plus the two cockpit signals the VM used + * to DROP on the floor (`queue` depth and the adopted session id). + * + * The `allowAutoMode` gate is security-relevant: `approve.mode="acceptEdits"` puts Claude into + * auto-accept-edits, and an operator who set `ALLOW_AUTO_MODE=0` on the host must not be overridden from + * a phone. The gate **fails CLOSED** — auto is refused until a successful fetch says otherwise — because + * the permissive default is the dangerous one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GateAutoModeTest { + + private class FakeController : TerminalSessionController { + private val channel = Channel(Channel.UNLIMITED) + val decisions = mutableListOf>() + + override fun controlEvents(): Flow = channel.receiveAsFlow() + override val output: Flow = emptyFlow() + override fun start() = Unit + override fun sendInput(data: String) = Unit + override fun resize(cols: Int, rows: Int) = Unit + override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit + override fun decideGate(epoch: Int, message: ClientMessage) { + decisions += epoch to message + } + + override fun close() = Unit + fun emit(event: SessionEvent) { + channel.trySend(event) + } + } + + private fun planGate(epoch: Int) = Gate(GateState(kind = GateKind.PLAN, detail = "plan", epoch = epoch)) + + // ── allowAutoMode fails CLOSED ─────────────────────────────────────────────────────────────── + + @Test + fun `auto-accept is refused until the host says it is allowed`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + fake.emit(planGate(1)) + + assertFalse(vm.uiState.value.allowAutoMode) // fail-closed default + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1) + + assertTrue(fake.decisions.isEmpty()) // NOTHING was sent + } + + @Test + fun `the other two plan affordances still work while auto is disabled`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + fake.emit(planGate(1)) + + vm.decide(GateDecision.APPROVE, epoch = 1) + vm.decide(GateDecision.REJECT, epoch = 1) + + assertEquals(2, fake.decisions.size) + assertEquals( + ClientMessage.Approve(mode = ApproveMode.DEFAULT), + fake.decisions[0].second, + ) + assertEquals(ClientMessage.Reject, fake.decisions[1].second) + } + + @Test + fun `auto-accept is sent once the host allows it`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + vm.setAutoModeAllowed(true) + fake.emit(planGate(7)) + + assertTrue(vm.uiState.value.allowAutoMode) + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 7) + + assertEquals( + listOf(7 to ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS)), + fake.decisions.toList(), + ) + } + + @Test + fun `revoking auto mode drops a later auto decision`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + vm.setAutoModeAllowed(true) + vm.setAutoModeAllowed(false) + fake.emit(planGate(3)) + + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 3) + + assertTrue(fake.decisions.isEmpty()) + } + + @Test + fun `the epoch stale-guard still outranks an allowed auto decision`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + vm.setAutoModeAllowed(true) + fake.emit(planGate(2)) + + vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1) // the gate on screen was epoch 1 + + assertTrue(fake.decisions.isEmpty()) + } + + // ── The `queue` frame (previously dropped) ─────────────────────────────────────────────────── + + @Test + fun `the queue frame publishes the pending depth`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + assertNull(vm.uiState.value.queueDepth) // unknown before the first frame + + fake.emit(Queued(3)) + assertEquals(3, vm.uiState.value.queueDepth) + + fake.emit(Queued(0)) + assertEquals(0, vm.uiState.value.queueDepth) + } + + // ── The adopted session id (the queue routes need it) ──────────────────────────────────────── + + @Test + fun `the adopted session id is published so a fresh spawn can be queued to`() = + runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + + assertNull(vm.uiState.value.sessionId) + + fake.emit(Adopted("11111111-2222-4333-8444-555555555555")) + assertEquals("11111111-2222-4333-8444-555555555555", vm.uiState.value.sessionId) + } + + @Test + fun `an exit clears the queue depth along with the gate`() = runTest(UnconfinedTestDispatcher()) { + val fake = FakeController() + val vm = GateViewModel(fake) + vm.bind(backgroundScope) + fake.emit(planGate(1)) + fake.emit(Queued(2)) + + fake.emit(Exited(code = 0)) + + assertNull(vm.uiState.value.gate) + assertNull(vm.uiState.value.queueDepth) // an exited session has no queue to show + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt index c5ed22b..91629b8 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt @@ -141,6 +141,9 @@ class GateViewModelTest { val fake = FakeController() val vm = GateViewModel(fake) vm.bind(backgroundScope) + // `acceptEdits` is gated by the host's `GET /config/ui` `allowAutoMode`, which fails CLOSED — so a + // test about the WIRE MAPPING has to opt in explicitly. The gate itself is `GateAutoModeTest`. + vm.setAutoModeAllowed(true) fake.emit(planGate(3)) vm.decide(GateDecision.APPROVE, epoch = 3) // default review diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/QueueViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/QueueViewModelTest.kt new file mode 100644 index 0000000..1bfcb4f --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/QueueViewModelTest.kt @@ -0,0 +1,245 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.QueueSnapshot +import wang.yaojia.webterm.api.models.QueueWriteOutcome +import wang.yaojia.webterm.api.routes.ApiClientError +import java.util.UUID + +/** + * [QueueViewModel] (W2 follow-up queue) — the write/read side of `POST|GET|DELETE + * /live-sessions/:id/queue`. + * + * The load-bearing properties asserted here are the ones a screenshot cannot show: + * - the prompt reaches the gateway **VERBATIM** (no trim, no normalisation, no client-side `\r`) and + * `appendEnter` stays a FLAG, so the SERVER materialises the carriage return (invariant #9); + * - every [QueueWriteOutcome] maps to its own UI notice, and a **429 is never auto-retried**; + * - a 503 permanently hides the affordance for the host (`isSupported = false`); + * - the authoritative depth comes from the server (`Ok(length)` / the `queue` WS frame), never from a + * local increment. + * The panel's Compose presentation is device-QA (plan §7). + */ +class QueueViewModelTest { + + private companion object { + val SESSION: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + } + + /** Records every call and replays scripted outcomes in order (the last one repeats). */ + private class FakeGateway( + private val snapshots: List = listOf(QueueSnapshot()), + private val writes: List = listOf(QueueWriteOutcome.Ok(1)), + ) : QueueGateway { + val enqueued = mutableListOf>() + val cleared = mutableListOf() + var snapshotCalls: Int = 0 + + override suspend fun snapshot(id: UUID): QueueSnapshot { + val next = snapshots[minOf(snapshotCalls, snapshots.lastIndex)] + snapshotCalls += 1 + if (next is Throwable) throw next + return next as QueueSnapshot + } + + override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome { + enqueued += Triple(id, text, appendEnter) + return writes[minOf(enqueued.size - 1, writes.lastIndex)] + } + + override suspend fun clear(id: UUID): QueueWriteOutcome { + cleared += id + return writes[minOf(cleared.size - 1, writes.lastIndex)] + } + } + + // ── Verbatim discipline (invariant #9) ─────────────────────────────────────────────────────── + + @Test + fun `the prompt reaches the gateway byte-for-byte with appendEnter as a flag`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Ok(2))) + val vm = QueueViewModel(gateway) + + val raw = " run the tests\nthen commit " + vm.enqueue(SESSION, raw) + + assertEquals(1, gateway.enqueued.size) + val (id, text, appendEnter) = gateway.enqueued.single() + assertEquals(SESSION, id) + assertEquals(raw, text) // no trim, no normalisation + assertFalse(text.contains('\r')) // the CR is the SERVER's job + assertTrue(appendEnter) + assertEquals(2, vm.uiState.value.depth) // authoritative depth from Ok(length) + } + + @Test + fun `an empty prompt is never sent`() = runTest { + val gateway = FakeGateway() + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "") + + assertTrue(gateway.enqueued.isEmpty()) + assertFalse(QueueViewModel.canSend("")) + // whitespace IS legitimate shell input — only EMPTY is refused + assertTrue(QueueViewModel.canSend(" ")) + } + + // ── Outcome → UI notice ────────────────────────────────────────────────────────────────────── + + @Test + fun `a full queue is reported and nothing is retried`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Full)) + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "one more") + + assertEquals(QueueNotice.Full, vm.uiState.value.notice) + assertEquals(1, gateway.enqueued.size) + } + + @Test + fun `a rate limit is surfaced and NEVER auto-retried`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.RateLimited)) + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "prompt") + + assertEquals(QueueNotice.RateLimited, vm.uiState.value.notice) + assertEquals(1, gateway.enqueued.size) // exactly one attempt — a retry burns the shared bucket + assertTrue(vm.uiState.value.isSupported) // 429 is transient, not a capability answer + } + + @Test + fun `a 503 hides the affordance for this host`() = runTest { + val gateway = FakeGateway(writes = listOf(QueueWriteOutcome.Disabled)) + val vm = QueueViewModel(gateway) + + vm.enqueue(SESSION, "prompt") + + assertFalse(vm.uiState.value.isSupported) + assertEquals(QueueNotice.Disabled, vm.uiState.value.notice) + } + + @Test + fun `an oversized prompt asks for a shorter one`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.TooLarge))) + vm.enqueue(SESSION, "x".repeat(64)) + assertEquals(QueueNotice.TooLarge, vm.uiState.value.notice) + } + + @Test + fun `a gone session is reported as gone`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.SessionGone))) + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.SessionGone, vm.uiState.value.notice) + } + + @Test + fun `a server rejection carries the server's own inert message`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.Rejected(403, "forbidden")))) + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.Rejected("forbidden"), vm.uiState.value.notice) + } + + @Test + fun `a transport failure surfaces as unreachable and keeps the last-good queue`() = runTest { + val vm = QueueViewModel( + object : QueueGateway { + override suspend fun snapshot(id: UUID): QueueSnapshot = QueueSnapshot(2, listOf("a", "b")) + override suspend fun enqueue(id: UUID, text: String, appendEnter: Boolean): QueueWriteOutcome = + throw java.io.IOException("boom") + + override suspend fun clear(id: UUID): QueueWriteOutcome = QueueWriteOutcome.Ok(0) + }, + ) + + vm.refresh(SESSION) + assertEquals(listOf("a", "b"), vm.uiState.value.items) + + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.Unreachable, vm.uiState.value.notice) + assertEquals(listOf("a", "b"), vm.uiState.value.items) // last-good kept + } + + // ── Read side + cancel-all ─────────────────────────────────────────────────────────────────── + + @Test + fun `refresh publishes the queued prompts verbatim`() = runTest { + val queued = listOf("first prompt", " second\twith tabs") + val vm = QueueViewModel(FakeGateway(snapshots = listOf(QueueSnapshot(2, queued)))) + + vm.refresh(SESSION) + + assertEquals(2, vm.uiState.value.depth) + assertEquals(queued, vm.uiState.value.items) + assertNull(vm.uiState.value.notice) + } + + @Test + fun `a 404 on refresh reports the session gone`() = runTest { + val vm = QueueViewModel(FakeGateway(snapshots = listOf(ApiClientError.SessionNotFound))) + vm.refresh(SESSION) + assertEquals(QueueNotice.SessionGone, vm.uiState.value.notice) + } + + @Test + fun `cancel-all empties the queue and drives the depth from the server`() = runTest { + val gateway = FakeGateway( + snapshots = listOf(QueueSnapshot(2, listOf("a", "b"))), + writes = listOf(QueueWriteOutcome.Ok(0)), + ) + val vm = QueueViewModel(gateway) + vm.refresh(SESSION) + + vm.clearAll(SESSION) + + assertEquals(listOf(SESSION), gateway.cleared) + assertEquals(0, vm.uiState.value.depth) + assertTrue(vm.uiState.value.items.isEmpty()) + } + + // ── The live `queue` WS frame ──────────────────────────────────────────────────────────────── + + @Test + fun `the queue frame is the authoritative depth and invalidates stale items`() = runTest { + val gateway = FakeGateway(snapshots = listOf(QueueSnapshot(1, listOf("a")))) + val vm = QueueViewModel(gateway) + vm.refresh(SESSION) + + vm.onServerDepth(3) // the server drained/accepted entries we did not send + + assertEquals(3, vm.uiState.value.depth) + // The items list is now known-stale: it must NOT claim 1 entry while the depth says 3. + assertTrue(vm.uiState.value.isItemsStale) + } + + @Test + fun `a zero-depth frame clears the item list outright`() = runTest { + val vm = QueueViewModel(FakeGateway(snapshots = listOf(QueueSnapshot(1, listOf("a"))))) + vm.refresh(SESSION) + + vm.onServerDepth(0) + + assertEquals(0, vm.uiState.value.depth) + assertTrue(vm.uiState.value.items.isEmpty()) + assertFalse(vm.uiState.value.isItemsStale) + } + + @Test + fun `dismissing a notice leaves the queue itself untouched`() = runTest { + val vm = QueueViewModel(FakeGateway(writes = listOf(QueueWriteOutcome.Full))) + vm.refresh(SESSION) + vm.enqueue(SESSION, "prompt") + assertEquals(QueueNotice.Full, vm.uiState.value.notice) + + vm.dismissNotice() + + assertNull(vm.uiState.value.notice) + assertEquals(0, vm.uiState.value.depth) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListPersistenceTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListPersistenceTest.kt new file mode 100644 index 0000000..0db023b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListPersistenceTest.kt @@ -0,0 +1,293 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.LiveSessionInfo +import wang.yaojia.webterm.hostregistry.Host +import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore +import wang.yaojia.webterm.session.UnreadLedger +import wang.yaojia.webterm.wire.ClaudeStatus +import java.util.UUID + +/** + * [SessionListViewModel] — the two things that make it stop lying across a process death and a host + * removal: + * 1. the DURABLE watermark seam ([PersistedUnreadWatermarkStore] over `:host-registry`'s + * `SessionWatermarkStore`), including the one-shot [SessionListViewModel.useWatermarkStore] install + * the composition root uses and the rule that it can never swap a store out from under a loaded + * ledger; + * 2. host removal — which must reach the [HostRemovalGateway] with the removed host's live session ids + * (so its watermarks go too) and must NOT drop the row on failure. + * + * The reducer itself ([UnreadLedger]) is NOT retested here — this is only about WHERE the watermark + * lives. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SessionListPersistenceTest { + + private companion object { + const val BASE_A = "http://a:3000" + const val BASE_B = "http://b:3000" + val ID_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + val ID_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666") + + fun host(id: String, name: String, baseUrl: String): Host = + Host.create(id = id, name = name, baseUrl = baseUrl)!! + + fun session(id: UUID, lastOutputAt: Long? = null) = LiveSessionInfo( + id = id, + createdAt = 1_700_000_000_000L, + clientCount = 1, + status = ClaudeStatus.WORKING, + exited = false, + cwd = null, + title = null, + cols = 80, + rows = 24, + telemetry = null, + lastOutputAt = lastOutputAt, + ) + } + + private class FakeHostStore(initial: List) : HostStore { + var hosts: List = initial + override suspend fun loadAll(): List = hosts + override suspend fun upsert(host: Host): List = hosts + override suspend fun remove(id: String): List { + hosts = hosts.filterNot { it.id == id } + return hosts + } + } + + private class FakeGateway(private val list: List) : SessionListGateway { + override suspend fun liveSessions(): List = list + override suspend fun killSession(id: UUID) = Unit + } + + /** + * Stands in for the production `HostRemover`: it OWNS the removal, so it drops the host from the + * store exactly like the real one does (a fake that only records would let the VM's re-resolve pass + * against a host list no device would ever show). + */ + private class FakeRemovalGateway( + private val hosts: FakeHostStore? = null, + private val error: Throwable? = null, + ) : HostRemovalGateway { + val calls = mutableListOf>>() + override suspend fun removeHost(hostId: String, sessionIds: List) { + calls += hostId to sessionIds + error?.let { throw it } + hosts?.remove(hostId) + } + } + + // ── The durable watermark seam ──────────────────────────────────────────────────────────────── + + @Test + fun `a persisted watermark survives a new view model over the same store`() = + runTest(UnconfinedTestDispatcher()) { + val backing = InMemorySessionWatermarkStore() + val store = PersistedUnreadWatermarkStore(backing) + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val gateway = FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) + + val first = SessionListViewModel(hostStore = hosts, gatewayFactory = { gateway }, watermarkStore = store) + first.refresh() + assertTrue(first.uiState.value.rows.single().isUnread) + first.markSeen(ID_1) + assertFalse(first.uiState.value.rows.single().isUnread) + + // A fresh VM over the SAME durable store == the process restarted. + val second = SessionListViewModel(hostStore = hosts, gatewayFactory = { gateway }, watermarkStore = store) + second.refresh() + assertFalse(second.uiState.value.rows.single().isUnread) + } + + @Test + fun `the adapter round-trips the ledger map through the host-registry store`() = runTest { + val backing = InMemorySessionWatermarkStore() + val store = PersistedUnreadWatermarkStore(backing) + + store.save(UnreadLedger(mapOf(ID_1.toString() to 42L))) + + assertEquals(mapOf(ID_1.toString() to 42L), store.load().watermarks) + assertEquals(mapOf(ID_1.toString() to 42L), backing.loadAll()) + } + + @Test + fun `the adapter drops ids the durable store refuses`() = runTest { + val store = PersistedUnreadWatermarkStore(InMemorySessionWatermarkStore()) + + // "not-a-uuid" fails the frozen v4 session-id validation inside the store. + store.save(UnreadLedger(mapOf(ID_1.toString() to 7L, "not-a-uuid" to 9L))) + + assertEquals(mapOf(ID_1.toString() to 7L), store.load().watermarks) + } + + @Test + fun `useWatermarkStore installs the durable store before the first load`() = + runTest(UnconfinedTestDispatcher()) { + val backing = InMemorySessionWatermarkStore() + backing.replaceAll(mapOf(ID_1.toString() to 100L)) // already seen up to 100 + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + ) + + assertTrue(vm.useWatermarkStore(PersistedUnreadWatermarkStore(backing))) + vm.refresh() + + assertFalse(vm.uiState.value.rows.single().isUnread) // the persisted watermark was honoured + } + + @Test + fun `useWatermarkStore never overrides an explicitly injected store`() = + runTest(UnconfinedTestDispatcher()) { + val injected = InMemoryUnreadWatermarkStore(UnreadLedger(mapOf(ID_1.toString() to 100L))) + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + watermarkStore = injected, + ) + + assertFalse(vm.useWatermarkStore(InMemoryUnreadWatermarkStore())) + vm.refresh() + + assertFalse(vm.uiState.value.rows.single().isUnread) // still the injected store's watermark + } + + @Test + fun `useWatermarkStore is refused once the ledger has loaded`() = runTest(UnconfinedTestDispatcher()) { + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + ) + vm.refresh() // loads the ledger from the default in-memory store + + assertFalse(vm.useWatermarkStore(InMemoryUnreadWatermarkStore())) + } + + @Test + fun `a durable store that throws degrades to no watermarks instead of crashing`() = + runTest(UnconfinedTestDispatcher()) { + val exploding = object : UnreadWatermarkStore { + override suspend fun load(): UnreadLedger = throw IllegalStateException("keystore hiccup") + override suspend fun save(ledger: UnreadLedger) = throw IllegalStateException("disk full") + } + val vm = SessionListViewModel( + hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))), + gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) }, + watermarkStore = exploding, + ) + + vm.refresh() + assertTrue(vm.uiState.value.rows.single().isUnread) // no watermarks → unread, never a crash + vm.markSeen(ID_1) // must not throw either + } + + // ── Host removal ───────────────────────────────────────────────────────────────────────────── + + @Test + fun `removing the active host hands the gateway its live session ids`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A), host("h2", "desktop", BASE_B))) + val removal = FakeRemovalGateway(hosts) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1), session(ID_2))) }, + removalGateway = removal, + ) + vm.refresh() + + vm.removeActiveHost() + + assertEquals( + listOf("h1" to listOf(ID_1.toString(), ID_2.toString())), + removal.calls.toList(), + ) + // The list re-resolves onto the surviving host. + assertEquals("h2", vm.uiState.value.activeHostId) + assertEquals(listOf("h2"), vm.uiState.value.hosts.map { it.id }) + } + + @Test + fun `removing the only host lands on the empty pair-a-host state`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1))) }, + removalGateway = FakeRemovalGateway(hosts), + ) + vm.refresh() + + vm.removeActiveHost() + + assertNull(vm.uiState.value.activeHostId) + assertTrue(vm.uiState.value.rows.isEmpty()) + } + + @Test + fun `a failed removal leaves the host in place rather than half-removing it`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val removal = FakeRemovalGateway(hosts, error = IllegalStateException("push DELETE failed")) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1))) }, + removalGateway = removal, + ) + vm.refresh() + + vm.removeActiveHost() + + assertEquals(1, removal.calls.size) + assertEquals("h1", vm.uiState.value.activeHostId) + assertTrue(vm.uiState.value.hasRemoveError) + } + + @Test + fun `useRemovalGateway enables removal for a view model built without one`() = + runTest(UnconfinedTestDispatcher()) { + val hosts = FakeHostStore(listOf(host("h1", "laptop", BASE_A))) + val removal = FakeRemovalGateway(hosts) + val vm = SessionListViewModel( + hostStore = hosts, + gatewayFactory = { FakeGateway(listOf(session(ID_1))) }, + ) + vm.refresh() + + vm.removeActiveHost() // no gateway installed yet → no-op + assertTrue(removal.calls.isEmpty()) + assertEquals(1, hosts.hosts.size) + + assertTrue(vm.useRemovalGateway(removal)) + assertFalse(vm.useRemovalGateway(FakeRemovalGateway(hosts))) // one-shot + vm.removeActiveHost() + + assertEquals(listOf("h1" to listOf(ID_1.toString())), removal.calls.toList()) + assertTrue(hosts.hosts.isEmpty()) + } + + @Test + fun `removeActiveHost with no active host is a no-op`() = runTest(UnconfinedTestDispatcher()) { + val removal = FakeRemovalGateway() + val vm = SessionListViewModel( + hostStore = FakeHostStore(emptyList()), + gatewayFactory = { FakeGateway(emptyList()) }, + removalGateway = removal, + ) + vm.refresh() + + vm.removeActiveHost() + + assertTrue(removal.calls.isEmpty()) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SurfaceCopyMappingTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SurfaceCopyMappingTest.kt new file mode 100644 index 0000000..e8eb8a1 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SurfaceCopyMappingTest.kt @@ -0,0 +1,106 @@ +package wang.yaojia.webterm.viewmodels + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.components.queueNoticeText +import wang.yaojia.webterm.screens.copyResultText +import wang.yaojia.webterm.screens.rotationStatusText +import wang.yaojia.webterm.terminalview.TerminalCopyResult +import wang.yaojia.webterm.wiring.RotationOutcome +import wang.yaojia.webterm.wiring.RotationStage +import java.time.Duration +import java.time.Instant + +/** + * The pure copy mappings behind three surfaces whose PIXELS are device-QA but whose *choice of what to + * say* is a correctness property: + * - [queueNoticeText] — every queue failure has its own honest line, and a server-supplied message is + * never allowed to render as the literal "null"; + * - [rotationStatusText] — a silent renewal stays SILENT, and only the three actionable outcomes speak; + * - [copyResultText] — a refused clipboard write says so instead of pretending it copied. + * None of them may leak a secret: no copied text, no `Throwable.message`, no credential. + */ +class SurfaceCopyMappingTest { + + // ── Queue notices ──────────────────────────────────────────────────────────────────────────── + + @Test + fun `every queue notice has its own non-blank line`() { + val notices = listOf( + QueueNotice.Full, + QueueNotice.TooLarge, + QueueNotice.Disabled, + QueueNotice.SessionGone, + QueueNotice.RateLimited, + QueueNotice.Unreachable, + QueueNotice.Rejected("forbidden"), + ) + val texts = notices.map(::queueNoticeText) + + assertTrue(texts.all { it.isNotBlank() }) + assertEquals(notices.size, texts.distinct().size, "each notice needs its own copy: $texts") + } + + @Test + fun `the rate-limit line states the shared limit and offers no retry`() { + val text = queueNoticeText(QueueNotice.RateLimited) + assertTrue(text.contains("20"), text) // the shared 20/min/IP bucket, named honestly + assertTrue(text.contains("手动"), text) // any retry is the USER's, never automatic + } + + @Test + fun `a rejection with no server message falls back to app copy`() { + val text = queueNoticeText(QueueNotice.Rejected(null)) + assertTrue(text.isNotBlank()) + assertFalse(text.contains("null"), text) + } + + @Test + fun `a server message is surfaced verbatim`() { + assertTrue(queueNoticeText(QueueNotice.Rejected("worktree is dirty")).contains("worktree is dirty")) + } + + // ── Rotation status ────────────────────────────────────────────────────────────────────────── + + @Test + fun `a healthy or in-progress rotation says nothing`() { + assertNull(rotationStatusText(null)) + assertNull(rotationStatusText(RotationOutcome.NotEnrolled)) + assertNull(rotationStatusText(RotationOutcome.Renewed)) + assertNull(rotationStatusText(RotationOutcome.Recovered)) + assertNull(rotationStatusText(RotationOutcome.NotDue(Instant.EPOCH))) + assertNull(rotationStatusText(RotationOutcome.BackingOff(Instant.EPOCH))) + } + + @Test + fun `the three actionable outcomes all speak`() { + assertNotNull(rotationStatusText(RotationOutcome.ReEnrollRequired(Duration.ofDays(40)))) + assertNotNull(rotationStatusText(RotationOutcome.NotConfigured("device-1"))) + assertNotNull(rotationStatusText(RotationOutcome.Failed(RotationStage.RENEW, "Http(429)"))) + } + + @Test + fun `a failure line carries the error SHAPE and says the current cert still works`() { + val text = rotationStatusText(RotationOutcome.Failed(RotationStage.RECOVER, "Http(403,revoked)")) + assertNotNull(text) + assertTrue(text!!.contains("Http(403,revoked)"), text) + assertTrue(text.contains("recover"), text) // which STEP failed + assertTrue(text.contains("仍然有效"), text) // the prior identity is untouched + } + + // ── Copy-out ───────────────────────────────────────────────────────────────────────────────── + + @Test + fun `every copy outcome is reported distinctly and a refusal is not called success`() { + val texts = TerminalCopyResult.entries.map(::copyResultText) + copyResultText(null) + + assertTrue(texts.all { it.isNotBlank() }) + assertEquals(texts.size, texts.distinct().size, "each copy outcome needs its own copy: $texts") + // A refused clipboard write must NOT read like a success. + assertFalse(copyResultText(TerminalCopyResult.CLIPBOARD_UNAVAILABLE).contains("已复制")) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationSchedulerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationSchedulerTest.kt new file mode 100644 index 0000000..cb50104 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationSchedulerTest.kt @@ -0,0 +1,331 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError +import wang.yaojia.webterm.api.enroll.DeviceRenewalState +import wang.yaojia.webterm.api.enroll.RotationPolicy +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.tlsandroid.CertStore +import wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot +import wang.yaojia.webterm.tlsandroid.EnrollmentRecord +import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore +import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher +import wang.yaojia.webterm.tlsandroid.StoredIdentityMetadata +import java.io.IOException +import java.time.Duration +import java.time.Instant + +/** + * The silent-rotation DRIVER: it reads the installed identity's timing, asks the pure `RotationPolicy` + * what to do, and drives the matching call. iOS ships `CertificateRotationScheduler`; Android had no + * counterpart at all, so a device certificate simply died. + * + * The behaviours under test here are the ones that make it safe to fire on every app foreground: + * - it is IDEMPOTENT and single-flight — a second trigger while one pass is in flight coalesces, + * - a failure is SURFACED (observable outcome) and leaves the prior identity fully live, + * - a failure THROTTLES the next attempt instead of hammering a rate-limited control plane, and + * - nothing it logs or surfaces can carry a credential. + */ +class CertificateRotationSchedulerTest { + private companion object { + val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z") + val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z") + const val CP_URL = "https://cp.terminal.yaojia.wang" + + /** A base64-looking string standing in for credential material in an error message. */ + const val LEAKY_SECRET = "MIIBfzCCASWgAwIBAgIUSECRETCSRBYTES" + } + + private val logLines = mutableListOf() + private var clock: Instant = RENEW_AFTER + private var snapshot: DeviceRotationSnapshot? = snapshot() + private var readFault: Exception? = null + private val renewUrls = mutableListOf() + private val recoverUrls = mutableListOf() + private var renewFault: Exception? = null + private var recoverFault: Exception? = null + + /** Optional gate a test can arm to hold `renew` suspended (single-flight coalescing). */ + private var renewGate: CompletableDeferred? = null + + private fun snapshot( + notAfter: Instant? = NOT_AFTER, + renewAfter: Instant? = RENEW_AFTER, + controlPlaneUrl: String = CP_URL, + ): DeviceRotationSnapshot = DeviceRotationSnapshot( + renewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter), + controlPlaneUrl = controlPlaneUrl, + ) + + private fun scheduler(): CertificateRotationScheduler = CertificateRotationScheduler( + readState = { + readFault?.let { throw it } + snapshot + }, + renew = { url -> + renewUrls += url + renewGate?.await() + renewFault?.let { throw it } + }, + recover = { url -> + recoverUrls += url + recoverFault?.let { throw it } + }, + now = { clock }, + log = { logLines += it }, + ) + + // ── the wait branches ────────────────────────────────────────────────────────────────────── + + @Test + fun nothingEnrolledDoesNoWorkAtAll() = runTest { + snapshot = null + + assertEquals(RotationOutcome.NotEnrolled, scheduler().runIfDue()) + + assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty()) + } + + @Test + fun aCertificateInsideItsRenewWindowIsLeftAlone() = runTest { + clock = RENEW_AFTER.minusSeconds(1) + + assertEquals(RotationOutcome.NotDue(RENEW_AFTER), scheduler().runIfDue()) + + assertTrue(renewUrls.isEmpty(), "the common case must be free") + } + + // ── renew ────────────────────────────────────────────────────────────────────────────────── + + @Test + fun aDueCertificateRenewsAgainstThePersistedControlPlane() = runTest { + val subject = scheduler() + + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + + assertEquals(listOf(CP_URL), renewUrls, "renew targets the control plane the device enrolled with") + assertTrue(recoverUrls.isEmpty()) + assertEquals(RotationOutcome.Renewed, subject.lastOutcome.value) + } + + @Test + fun aBlankControlPlaneUrlRefusesToGuessAHost() = runTest { + // A record written before the URL was persisted has no rotation target. Renewing against a + // compiled-in default would talk to somebody else's control plane. + snapshot = snapshot(controlPlaneUrl = " ") + + assertEquals(RotationOutcome.NotConfigured("dev-1"), scheduler().runIfDue()) + + assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty(), "no request without a known host") + } + + // ── recover ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun anExpiredCertificateRecoversAndNeverAttemptsAnMtlsRenew() = runTest { + clock = NOT_AFTER.plus(Duration.ofDays(8)) + + assertEquals(RotationOutcome.Recovered, scheduler().runIfDue()) + + // An expired leaf cannot authenticate its own renewal, so a renew attempt here is not merely + // useless — it is the production deadlock this whole path exists to break. + assertTrue(renewUrls.isEmpty(), "an expired leaf must never be sent to the mTLS renew route") + assertEquals(listOf(CP_URL), recoverUrls) + } + + @Test + fun aCertificateExpiredBeyondTheGraceWindowIsTerminalAndSilent() = runTest { + clock = NOT_AFTER.plus(Duration.ofDays(31)) + + val outcome = scheduler().runIfDue() + + assertEquals(RotationOutcome.ReEnrollRequired(Duration.ofDays(31)), outcome) + assertTrue(renewUrls.isEmpty() && recoverUrls.isEmpty(), "no request can succeed — make none") + } + + // ── failure posture ──────────────────────────────────────────────────────────────────────── + + @Test + fun aFailedRenewIsSurfacedAndTheStageIsIdentified() = runTest { + renewFault = DeviceEnrollmentError.Http(429, "rate_limited") + val subject = scheduler() + + val outcome = subject.runIfDue() + + assertEquals(RotationOutcome.Failed(RotationStage.RENEW, "Http(429,rate_limited)"), outcome) + // Observable, not only a log line — iOS surfaces the same as `isCertificateRenewalFailing`. + assertEquals(outcome, subject.lastOutcome.value) + } + + @Test + fun aFailedRecoveryIsSurfacedSeparatelyFromAFailedRenew() = runTest { + clock = NOT_AFTER.plus(Duration.ofDays(8)) + recoverFault = IOException("connection reset") + + assertEquals( + RotationOutcome.Failed(RotationStage.RECOVER, "IOException"), + scheduler().runIfDue(), + ) + } + + @Test + fun aStateReadFaultIsSurfacedRatherThanReportedAsNotEnrolled() = runTest { + // Degrading a Tink/keystore fault to "nothing enrolled" would disable rotation forever. + readFault = IllegalStateException("keystore unavailable") + + assertEquals( + RotationOutcome.Failed(RotationStage.READ_STATE, "IllegalStateException"), + scheduler().runIfDue(), + ) + } + + @Test + fun aFailureThrottlesTheNextPassInsteadOfHammering() = runTest { + renewFault = IOException("connection reset") + val subject = scheduler() + subject.runIfDue() + + clock = RENEW_AFTER.plus(Duration.ofMinutes(1)) + val second = subject.runIfDue() + + assertEquals( + RotationOutcome.BackingOff(RENEW_AFTER.plus(RotationPolicy.DEFAULT_FAILURE_BACKOFF)), + second, + ) + assertEquals(1, renewUrls.size, "the control plane rate-limits renewals — one failure, one call") + } + + @Test + fun aSuccessClearsTheThrottle() = runTest { + renewFault = IOException("connection reset") + val subject = scheduler() + subject.runIfDue() + + renewFault = null + clock = RENEW_AFTER.plus(RotationPolicy.DEFAULT_FAILURE_BACKOFF) + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + // Still due (the fake state does not change), so a following pass attempts again immediately — + // proving the recorded failure was cleared rather than throttling forever. + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + assertEquals(3, renewUrls.size) + } + + // ── single-flight / idempotence ──────────────────────────────────────────────────────────── + + @Test + fun aSecondTriggerWhileOneIsInFlightCoalescesIntoTheSamePass() = runTest { + val gate = CompletableDeferred() + renewGate = gate + val subject = scheduler() + + val first = async { subject.runIfDue() } + val second = async { subject.runIfDue() } + // Let both actually start: the first parks inside `renew`, the second must then find the pass + // already in flight and park on ITS result. Without this the first pass would finish before + // the second even began, and the test would prove nothing. + testScheduler.runCurrent() + gate.complete(Unit) + + assertEquals(RotationOutcome.Renewed, first.await()) + assertEquals(RotationOutcome.Renewed, second.await()) + assertEquals(1, renewUrls.size, "two foreground triggers must not stampede two renewals") + } + + @Test + fun aCancelledPassDoesNotWedgeTheScheduler() = runTest { + val gate = CompletableDeferred() + renewGate = gate + val subject = scheduler() + + val cancelled = launch { subject.runIfDue() } + cancelled.cancel() + cancelled.join() + + // The in-flight slot must have been released, or every later pass would await a deferred that + // can never complete — rotation dead until the process restarts. + renewGate = null + assertEquals(RotationOutcome.Renewed, subject.runIfDue()) + } + + // ── credential hygiene ───────────────────────────────────────────────────────────────────── + + @Test + fun neitherTheLogNorTheOutcomeCanCarryACredential() = runTest { + // A real exception message can embed a URL, a body or, as here, cert/CSR material. The driver + // must report the error SHAPE only — never the message. + renewFault = IllegalStateException("csr=$LEAKY_SECRET token=hunter2") + val subject = scheduler() + + val outcome = subject.runIfDue() + + val rendered = "$outcome ${logLines.joinToString(" ")}" + assertFalse(rendered.contains(LEAKY_SECRET), "no certificate/CSR material may be logged or surfaced") + assertFalse(rendered.contains("hunter2"), "no token/passphrase may be logged or surfaced") + assertFalse(rendered.contains("csr="), "not even the message fragment") + assertEquals(RotationOutcome.Failed(RotationStage.RENEW, "IllegalStateException"), outcome) + } + + @Test + fun theSuccessLogNamesTheDeviceAndNothingElse() = runTest { + scheduler().runIfDue() + + assertTrue(logLines.isNotEmpty(), "a silent rotation must still leave a trace") + val rendered = logLines.joinToString(" ") + assertTrue(rendered.contains("dev-1"), "the non-secret device id is what makes the line useful") + assertFalse(rendered.contains("MII"), "no DER/PEM material") + } + + @Test + fun theLastOutcomeStartsEmpty() = runTest { + assertNull(scheduler().lastOutcome.value, "nothing is claimed before a pass has run") + } + + // ── the composition root ─────────────────────────────────────────────────────────────────── + + @Test + fun createBuildsAWorkingSchedulerOverTheRealCollaborators() = runTest { + // `create` is the ONE place that knows both halves (:api-client + :client-tls-android). This + // smoke test runs it over the real DeviceRotationStore / DeviceEnroller types with empty + // stores, so a broken composition root fails here rather than silently at runtime. + val scheduler = CertificateRotationScheduler.create( + certStore = EmptyCertStore, + recordStore = EmptyRecordStore, + cacheRefresher = IdentityCacheRefresher {}, + sharedClient = { OkHttpClient() }, + mtlsTransport = { FakeHttpTransport() }, + plainTransport = { FakeHttpTransport() }, + ioDispatcher = StandardTestDispatcher(testScheduler), + now = { clock }, + log = { logLines += it }, + ) + + assertEquals(RotationOutcome.NotEnrolled, scheduler.runIfDue()) + } + + private object EmptyCertStore : CertStore { + override fun save(metadata: StoredIdentityMetadata): Unit = error("not used") + + override fun load(): StoredIdentityMetadata? = null + + override fun clear(): Unit = error("not used") + } + + private object EmptyRecordStore : EnrollmentRecordStore { + override fun save(record: EnrollmentRecord): Unit = error("not used") + + override fun load(): EnrollmentRecord? = null + + override fun clear(): Unit = error("not used") + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationTriggerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationTriggerTest.kt new file mode 100644 index 0000000..2d77f9f --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/CertificateRotationTriggerTest.kt @@ -0,0 +1,89 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant + +/** + * [CertificateRotationTrigger] — the app-lifecycle edge that finally CALLS + * [CertificateRotationScheduler]. Without it the scheduler is dead code and a device certificate simply + * expires, stranding the app (the iOS `AppCoordinator.runCertificateRotationIfDue()` counterpart). + * + * The trigger is deliberately fire-and-forget: `AppEnvironment.warmUp()` is awaited by the terminal + * screen's readiness gate, so a rotation's NETWORK call must never be able to hold the UI. The + * properties asserted here are the ones that make that safe — it launches rather than suspends, it never + * lets a failure escape, and a scheduler that cannot even be constructed does not crash the app. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CertificateRotationTriggerTest { + + /** A scheduler whose store read is scripted; `null` state ⇒ NotEnrolled (no network at all). */ + private fun scheduler( + readState: suspend () -> wang.yaojia.webterm.tlsandroid.DeviceRotationSnapshot? = { null }, + ) = CertificateRotationScheduler( + readState = readState, + renew = { }, + recover = { }, + now = { Instant.parse("2026-07-30T00:00:00Z") }, + log = { }, + ) + + @Test + fun `firing runs one rotation pass and publishes its outcome`() = runTest(UnconfinedTestDispatcher()) { + val live = scheduler() + val trigger = CertificateRotationTrigger(scope = this, scheduler = { live }) + + trigger.fire() + + assertEquals(RotationOutcome.NotEnrolled, live.lastOutcome.value) + } + + @Test + fun `a store fault becomes a Failed outcome, never a thrown exception`() = + runTest(UnconfinedTestDispatcher()) { + val live = scheduler(readState = { throw IllegalStateException("keystore unavailable") }) + val trigger = CertificateRotationTrigger(scope = this, scheduler = { live }) + + trigger.fire() // must not throw + + val outcome = live.lastOutcome.value + assertTrue(outcome is RotationOutcome.Failed, "expected Failed, got $outcome") + assertEquals(RotationStage.READ_STATE, (outcome as RotationOutcome.Failed).stage) + // Only the SHAPE is kept — never a message that could embed a URL or credential material. + assertEquals("IllegalStateException", outcome.errorShape) + } + + @Test + fun `a scheduler that cannot be resolved is reported, not crashed`() = runTest(UnconfinedTestDispatcher()) { + val errors = mutableListOf() + val trigger = CertificateRotationTrigger( + scope = this, + scheduler = { throw IllegalStateException("graph not ready") }, + onError = { errors += it }, + ) + + trigger.fire() + + assertEquals(1, errors.size) + } + + @Test + fun `fire never suspends the caller on the rotation itself`() = runTest { + // A scheduler whose read blocks forever: `fire()` must still return immediately. If it awaited the + // pass, this test would hang instead of completing (runTest would report the coroutine as stuck). + val live = scheduler(readState = { kotlinx.coroutines.awaitCancellation() }) + val scope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val trigger = CertificateRotationTrigger(scope = scope, scheduler = { live }) + + trigger.fire() + + assertNull(live.lastOutcome.value) // still in flight, and we got control back + scope.coroutineContext[kotlinx.coroutines.Job]?.cancel() + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt new file mode 100644 index 0000000..9843f2e --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt @@ -0,0 +1,192 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.hostregistry.AuthCookieRecord +import wang.yaojia.webterm.hostregistry.Host +import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore +import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore +import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore +import java.util.UUID + +/** + * [HostRemover] — un-pairing a host must erase EVERY trace keyed off it, in the one order that works. + * + * The bug this closes: `PushRegistrar.unregisterHost` had zero call sites, so a dropped host kept the + * device's FCM token forever and kept pushing notifications for a host the user had removed. + * + * Two rules are asserted because getting either wrong is worse than not removing at all: + * - **the remote DELETE happens FIRST**, while the host record and its `webterm_auth` cookie still + * exist — erase them first and the DELETE can neither be addressed nor authenticated; + * - **local erasure is all-or-nothing and `HostStore.remove` is LAST**, so a failure part-way leaves the + * host paired and the operation retryable, never a ghost with orphaned cookies. + * The remote DELETE itself is BEST-EFFORT: a host that is switched off must still be removable. + */ +class HostRemoverTest { + + private companion object { + const val BASE = "http://laptop.local:3000" + val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + val SESSION_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666") + + fun host(id: String = "h1"): Host = Host.create(id = id, name = "laptop", baseUrl = BASE)!! + } + + private class RecordingHostStore(initial: List) : HostStore { + var hosts: List = initial + var removeError: Throwable? = null + override suspend fun loadAll(): List = hosts + override suspend fun upsert(host: Host): List = hosts + override suspend fun remove(id: String): List { + removeError?.let { throw it } + hosts = hosts.filterNot { it.id == id } + return hosts + } + } + + /** Records the (host, token) pairs handed to the push DELETE — and whether it was called at all. */ + private class RecordingPush(private val error: Throwable? = null) : HostPushUnregister { + val calls = mutableListOf>() + var hostStillPairedAtCallTime: Boolean? = null + var probe: (() -> Boolean)? = null + + override suspend fun unregister(host: Host, token: String) { + hostStillPairedAtCallTime = probe?.invoke() + calls += host.id to token + error?.let { throw it } + } + } + + private suspend fun fixture( + hosts: RecordingHostStore = RecordingHostStore(listOf(host())), + push: RecordingPush = RecordingPush(), + token: String? = "fcm-token", + cookieJarClears: MutableList = mutableListOf(), + ): Triple { + val lastSession = InMemoryLastSessionStore() + val cookies = InMemoryAuthCookieStore() + val watermarks = InMemorySessionWatermarkStore() + lastSession.setLastSessionId(SESSION_1.toString(), "h1") + cookies.upsert( + AuthCookieRecord( + hostKey = "http://laptop.local:3000", + name = "webterm_auth", + value = "secret", + domain = "laptop.local", + path = "/", + expiresAtEpochMillis = Long.MAX_VALUE, + secure = false, + httpOnly = true, + hostOnly = true, + ), + ) + watermarks.replaceAll(mapOf(SESSION_1.toString() to 10L, SESSION_2.toString() to 20L)) + val remover = HostRemover( + hostStore = hosts, + lastSessionStore = lastSession, + authCookieStore = cookies, + watermarkStore = watermarks, + pushUnregister = push, + currentPushToken = { token }, + clearLiveCookies = { key -> cookieJarClears += key }, + log = { }, // android.util.Log is not mocked under JVM unit tests + ) + return Triple(remover, push, Quad(hosts, lastSession, cookies, watermarks)) + } + + private data class Quad( + val hosts: RecordingHostStore, + val lastSession: InMemoryLastSessionStore, + val cookies: InMemoryAuthCookieStore, + val watermarks: InMemorySessionWatermarkStore, + ) + + @Test + fun `removing a host erases every trace keyed off it`() = runTest { + val jarClears = mutableListOf() + val (remover, push, stores) = fixture(cookieJarClears = jarClears) + + remover.removeHost("h1", listOf(SESSION_1.toString(), SESSION_2.toString())) + + assertEquals(listOf("h1" to "fcm-token"), push.calls.toList()) // the DELETE finally happens + assertTrue(stores.hosts.hosts.isEmpty()) + assertNull(stores.lastSession.lastSessionId("h1")) + assertTrue(stores.cookies.loadAll().isEmpty()) + assertEquals(listOf("http://laptop.local:3000"), jarClears) // live jar too, not just at rest + assertTrue(stores.watermarks.loadAll().isEmpty()) + } + + @Test + fun `the remote DELETE runs while the host is still paired`() = runTest { + val hosts = RecordingHostStore(listOf(host())) + val push = RecordingPush() + push.probe = { hosts.hosts.isNotEmpty() } + val (remover, _, _) = fixture(hosts = hosts, push = push) + + remover.removeHost("h1", emptyList()) + + assertTrue(push.hostStillPairedAtCallTime == true) + } + + @Test + fun `an unreachable host is still removable`() = runTest { + val push = RecordingPush(error = java.io.IOException("host is off")) + val (remover, _, stores) = fixture(push = push) + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertTrue(stores.hosts.hosts.isEmpty()) // best-effort DELETE must not block the removal + } + + @Test + fun `with no push token the local state is still fully erased`() = runTest { + val push = RecordingPush() + val (remover, _, stores) = fixture(push = push, token = null) + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertTrue(push.calls.isEmpty()) // nothing to DELETE + assertTrue(stores.hosts.hosts.isEmpty()) + assertNull(stores.lastSession.lastSessionId("h1")) + } + + @Test + fun `a failing local erase leaves the host paired so the user can retry`() = runTest { + val hosts = RecordingHostStore(listOf(host())) + hosts.removeError = IllegalStateException("datastore write failed") + val (remover, _, stores) = fixture(hosts = hosts) + + assertThrows(IllegalStateException::class.java) { + kotlinx.coroutines.runBlocking { remover.removeHost("h1", listOf(SESSION_1.toString())) } + } + + assertEquals(1, stores.hosts.hosts.size) // still paired → retryable, never a ghost + } + + @Test + fun `an unknown host id is a no-op that touches nothing`() = runTest { + val push = RecordingPush() + val (remover, _, stores) = fixture(push = push) + + remover.removeHost("nope", listOf(SESSION_1.toString())) + + assertTrue(push.calls.isEmpty()) + assertEquals(1, stores.hosts.hosts.size) + assertFalse(stores.watermarks.loadAll().isEmpty()) // watermarks untouched + } + + @Test + fun `only the named sessions lose their watermarks`() = runTest { + val (remover, _, stores) = fixture() + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertEquals(mapOf(SESSION_2.toString() to 20L), stores.watermarks.loadAll()) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/UiConfigGateTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/UiConfigGateTest.kt new file mode 100644 index 0000000..104b673 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/UiConfigGateTest.kt @@ -0,0 +1,85 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.UiConfig +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * [UiConfigGate] — the client half of `GET /config/ui`'s `allowAutoMode`. + * + * `ApiClient.uiConfig()` was implemented and never called, so the plan-gate sheet offered + * "approve + auto-accept edits" even on a host whose operator had switched `ALLOW_AUTO_MODE` off. The + * gate closes that, and the ONE rule that matters is that **failure fails CLOSED**: an unreachable host, + * a pre-`/config/ui` server, or a garbled body must all mean "auto is not allowed", because the + * permissive default is the dangerous one. + */ +class UiConfigGateTest { + + private companion object { + val HOST_A: HostEndpoint = HostEndpoint.fromBaseUrl("http://a:3000")!! + val HOST_B: HostEndpoint = HostEndpoint.fromBaseUrl("http://b:3000")!! + } + + @Test + fun `auto mode is allowed when the host says so`() = runTest { + var calls = 0 + val gate = UiConfigGate { calls += 1; UiConfig(allowAutoMode = true) } + + assertTrue(gate.allowsAutoMode(HOST_A)) + assertEquals(1, calls) + } + + @Test + fun `auto mode is refused when the host disabled it`() = runTest { + val gate = UiConfigGate { UiConfig(allowAutoMode = false) } + assertFalse(gate.allowsAutoMode(HOST_A)) + } + + @Test + fun `a failed fetch fails CLOSED`() = runTest { + val gate = UiConfigGate { throw java.io.IOException("host unreachable") } + assertFalse(gate.allowsAutoMode(HOST_A)) + } + + @Test + fun `a successful answer is cached per host`() = runTest { + val seen = mutableListOf() + val gate = UiConfigGate { endpoint -> + seen += endpoint.baseUrl + UiConfig(allowAutoMode = endpoint == HOST_A) + } + + assertTrue(gate.allowsAutoMode(HOST_A)) + assertTrue(gate.allowsAutoMode(HOST_A)) // served from cache + assertFalse(gate.allowsAutoMode(HOST_B)) // a DIFFERENT host is fetched, not inherited + + assertEquals(listOf(HOST_A.baseUrl, HOST_B.baseUrl), seen.toList()) + } + + @Test + fun `a failure is NOT cached so a host that comes back is re-asked`() = runTest { + var attempt = 0 + val gate = UiConfigGate { + attempt += 1 + if (attempt == 1) throw java.io.IOException("down") else UiConfig(allowAutoMode = true) + } + + assertFalse(gate.allowsAutoMode(HOST_A)) // fail closed + assertTrue(gate.allowsAutoMode(HOST_A)) // recovered → now allowed + assertEquals(2, attempt) + } + + @Test + fun `cancellation propagates instead of being read as a policy answer`() = runTest { + val gate = UiConfigGate { throw CancellationException("scope cancelled") } + assertThrows(CancellationException::class.java) { + kotlinx.coroutines.runBlocking { gate.allowsAutoMode(HOST_A) } + } + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt index 6230355..67551d8 100644 --- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnroller.kt @@ -43,6 +43,17 @@ public class DeviceEnroller( // AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no // process restart. Optional so the JVM orchestration tests can construct the enroller without it. private val cacheRefresher: IdentityCacheRefresher? = null, + /** + * A SECOND client over a PLAIN (non-mTLS) transport, used ONLY by [recover]. It must not be the + * same client as [client]: an expired leaf cannot authenticate its own re-issuance, and an mTLS + * transport would present that expired leaf, which nginx refuses to forward at all (bare 400). + * Null ⇒ recovery is unavailable and [recover] throws rather than falling back to mTLS. + * + * It MUST target the same control plane as [client] — the enrollment record stores [client]'s + * base URL as the rotation target, so a recovery pointed elsewhere would rewrite the identity + * while leaving the record naming the original host. + */ + private val recoveryClient: DeviceEnrollmentClient? = null, ) { private val commitMutex = Mutex() @@ -97,6 +108,40 @@ public class DeviceEnroller( summaryOf(result) } + /** + * Recover an EXPIRED leaf: re-CSR from the SAME hardware key and POST it together with the lapsed + * certificate to `POST /device/:id/recover` over the PLAIN (non-mTLS) [recoveryClient]. + * + * This exists because `/device/:id/renew` is authenticated by the very certificate it renews, so a + * lapsed leaf deadlocks: no TLS terminator will even forward an expired client certificate (nginx + * answers a bare 400 under `ssl_verify_client optional`, and `optional_no_ca` tolerates only CHAIN + * errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). Without this call an expired device certificate + * means a full manual re-enroll. Possession is still proven — the CSR is self-signed by the same + * non-exportable key and the control plane enforces CSR proof-of-possession plus + * `CSR key == registered key` — and revocation, account/`:id` consistency and `notBefore` are all + * still enforced server-side; only `notAfter` is graced (30 days). + * + * Failure posture: everything before [commitIdentity]'s cert-store save is read-only, so a rejected + * or unreachable recovery leaves the prior identity and its key exactly as they were. + * + * Throws [EnrollmentStateException] when there is no [recoveryClient], no enrollment record, no + * stored certificate to present, or no hardware key — none of which a request could fix. + */ + public suspend fun recover(): CertificateSummary = commitMutex.withLock { + val recovery = recoveryClient + ?: throw EnrollmentStateException("no plain-HTTPS recovery client — refusing to recover over mTLS") + val record = recordStore.load() + ?: throw EnrollmentStateException("no enrollment record — nothing to recover") + val expiredLeaf = certStore.load()?.leafCertificate + ?: throw EnrollmentStateException("no stored device certificate — a fresh enroll is required") + val key = keyProvider.load(record.keyStoreAlias) + ?: throw EnrollmentStateException("device key missing — a fresh enroll is required") + val csr = CertificateSigningRequest.der(record.deviceName, key) + val result = recovery.recover(record.deviceId, expiredLeaf.encoded, csr) + commitIdentity(result, record.deviceName, record.keyStoreAlias) + summaryOf(result) + } + /** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */ public suspend fun remove(): Unit = commitMutex.withLock { certStore.clear() @@ -120,7 +165,13 @@ public class DeviceEnroller( deviceId = result.deviceId, deviceName = deviceName, keyStoreAlias = alias, + // Only an enroll 201 carries renewAfter; a renew/recover 201 does not, so this is 0 + // after a rotation. That is fine — DeviceRotationStore derives the live timing from + // the leaf certificate, never from this advisory field. renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L, + // Persist WHICH control plane issued this identity so silent rotation (which has no UI + // to ask on) renews against the same one instead of a compiled-in default. + controlPlaneUrl = client.baseUrl, ), ) certStore.save( diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStore.kt new file mode 100644 index 0000000..d07e424 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStore.kt @@ -0,0 +1,60 @@ +package wang.yaojia.webterm.tlsandroid + +import wang.yaojia.webterm.api.enroll.DeviceRenewalState +import wang.yaojia.webterm.api.enroll.RotationPolicy + +/** + * Everything a rotation pass needs to know, read from storage: the pure [renewalState] the decision + * table consumes plus [controlPlaneUrl], the host to rotate against. + * + * Carries no credential: only the non-secret device id, the leaf's timing, and a URL. Safe to log. + */ +public data class DeviceRotationSnapshot( + val renewalState: DeviceRenewalState, + val controlPlaneUrl: String, +) + +/** + * The READ side of silent certificate rotation — the Android counterpart of iOS + * `KeychainClientIdentityStore.renewalState()`, composing the two persisted stores into one snapshot. + * + * ### Why the timing comes from the LEAF, not the record + * `POST /device/:id/renew` and `POST /device/:id/recover` answer `{cert, caChain, notAfter}` — neither + * returns a `renewAfter` (only `/device/enroll` does). A driver that trusted + * [EnrollmentRecord.renewAfterEpochSeconds] would therefore see "unknown" after the very first + * rotation and report not-due until the certificate died — exactly once-and-never-again rotation. The + * live leaf's own `notBefore`/`notAfter` are always present and are what the TLS stack actually + * enforces, so they are the source of truth; [RotationPolicy.renewAfterFor] re-derives the advisory + * instant from them with the control plane's own 2/3-of-lifetime fraction. + * + * Deliberately I/O-only and framework-free (no `android.*` import), so the whole read path is JVM + * unit-tested. A store fault PROPAGATES rather than degrading to "nothing enrolled": silently + * reporting not-enrolled would disable rotation forever, whereas a surfaced failure is retried. + */ +public class DeviceRotationStore( + private val certStore: CertStore, + private val recordStore: EnrollmentRecordStore, +) { + /** + * The current rotation snapshot, or null when there is nothing to rotate — no enrollment record + * (fresh install / legacy `.p12`-only identity) or no live certificate (a renew has nothing to + * re-CSR for and a recovery has no lapsed leaf to present, so only a fresh enroll can help). + * + * Performs AndroidKeyStore/Tink-backed reads via the injected stores: call it OFF the main thread. + */ + public fun read(): DeviceRotationSnapshot? { + val record = recordStore.load() ?: return null + val leaf = certStore.load()?.leafCertificate ?: return null + return DeviceRotationSnapshot( + renewalState = DeviceRenewalState( + deviceId = record.deviceId, + notAfter = leaf.notAfter.toInstant(), + renewAfter = RotationPolicy.renewAfterFor( + notBefore = leaf.notBefore.toInstant(), + notAfter = leaf.notAfter.toInstant(), + ), + ), + controlPlaneUrl = record.controlPlaneUrl, + ) + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt index 0926361..119ab96 100644 --- a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordStore.kt @@ -8,12 +8,23 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream +import java.io.EOFException /** * B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted * [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR * subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign - * with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler. + * with, [renewAfterEpochSeconds] (0 = unknown) as issued at enroll time, and [controlPlaneUrl] — + * WHICH control plane to rotate against. + * + * [controlPlaneUrl] exists because rotation is silent: there is no screen to re-type the URL on, and + * falling back to a compiled-in default would renew against the wrong host. It is not a credential + * (iOS keeps the same value in plain `UserDefaults`); a record written before the field existed + * decodes to `""`, which the rotation driver treats as "cannot rotate" rather than guessing. + * + * [renewAfterEpochSeconds] is only ever ADVISORY and only ever set by an enroll: neither + * `/device/:id/renew` nor `/device/:id/recover` returns a `renewAfter`. The live timing therefore + * comes from the leaf certificate itself — see [DeviceRotationStore]. * * This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert * identity is what the handshake presents; this record only exists so renew can find the device and @@ -24,6 +35,8 @@ public data class EnrollmentRecord( val deviceName: String, val keyStoreAlias: String, val renewAfterEpochSeconds: Long, + /** Control-plane base URL this device enrolled against; `""` when unknown (a legacy record). */ + val controlPlaneUrl: String = "", ) { init { require(deviceId.isNotBlank()) { "deviceId must not be blank" } @@ -31,7 +44,7 @@ public data class EnrollmentRecord( } } -/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */ +/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — four UTF strings + one long). */ public object EnrollmentRecordCodec { public fun encode(record: EnrollmentRecord): ByteArray { val out = ByteArrayOutputStream() @@ -40,6 +53,7 @@ public object EnrollmentRecordCodec { data.writeUTF(record.deviceName) data.writeUTF(record.keyStoreAlias) data.writeLong(record.renewAfterEpochSeconds) + data.writeUTF(record.controlPlaneUrl) } return out.toByteArray() } @@ -53,11 +67,25 @@ public object EnrollmentRecordCodec { deviceName = data.readUTF(), keyStoreAlias = data.readUTF(), renewAfterEpochSeconds = data.readLong(), + controlPlaneUrl = readTrailingUtfOrBlank(data), ) } } catch (e: Exception) { throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e) } + + /** + * Read the trailing [EnrollmentRecord.controlPlaneUrl], tolerating its ABSENCE (a blob written + * before the field existed) as `""`. Only end-of-blob is tolerated: a record that ends exactly + * after the long is a valid older layout, whereas a mangled one still fails the reads above and is + * reported corrupt. Declaring an existing enrollment corrupt would strand a working device. + */ + private fun readTrailingUtfOrBlank(data: DataInputStream): String = + try { + data.readUTF() + } catch (_: EOFException) { + "" + } } /** diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt index f999228..e3134ed 100644 --- a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceEnrollerTest.kt @@ -5,15 +5,13 @@ import okhttp3.OkHttpClient import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError import wang.yaojia.webterm.testsupport.FakeHttpTransport import wang.yaojia.webterm.wire.HttpMethod -import java.security.KeyPairGenerator -import java.security.interfaces.ECPublicKey -import java.security.spec.ECGenParameterSpec /** * B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs @@ -30,53 +28,32 @@ class DeviceEnrollerTest { const val BASE = "https://cp.terminal.yaojia.wang" const val ALIAS = "test-device-key" - // Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory / - // CertificateSummaryReader parse them exactly as they parse a server-issued leaf. - const val LEAF_CN = "t1-device" - const val CA_CN = "webterm-device-ca" - const val LEAF_B64 = - "MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" + - "ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" + - "WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" + - "cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" + - "HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" + - "ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" + - "cdoleWDlqcMKU" - const val CA_B64 = - "MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" + - "dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" + - "YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" + - "e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" + - "4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" + - "Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" + - "YloHuEAg+kngzA33m52aWtublai4L+eybg==" + // Real self-signed P-256 X.509 certs + a software key double, shared with the rotation tests. + const val LEAF_CN = RotationTestFixtures.LEAF_CN + const val CA_CN = RotationTestFixtures.CA_CN - fun loginBody(): ByteArray = - """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray() + fun loginBody(): ByteArray = RotationTestFixtures.loginBody() - fun enrollBody(deviceId: String = "dev-1"): ByteArray = - """ - {"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"], - "notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z", - "renewAfter":"2026-09-05T00:00:00.000Z"} - """.trimIndent().toByteArray() + fun enrollBody(deviceId: String = "dev-1"): ByteArray = RotationTestFixtures.enrollBody(deviceId) - fun softwareKey(alias: String): HardwareBackedKey { - val kpg = KeyPairGenerator.getInstance("EC") - kpg.initialize(ECGenParameterSpec("secp256r1")) - val kp = kpg.generateKeyPair() - return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey) - } + fun softwareKey(alias: String): HardwareBackedKey = RotationTestFixtures.softwareKey(alias) } private val events = mutableListOf() private val transport = FakeHttpTransport() + + /** + * The recovery transport is a SEPARATE double on purpose: `/device/:id/recover` must ride a PLAIN + * (non-mTLS) client, because no TLS terminator forwards an expired client certificate. Using two + * transports is what lets these tests prove the routing, not just the request shape. + */ + private val recoveryTransport = FakeHttpTransport() private val certStore = RecordingCertStore(events) private val recordStore = RecordingRecordStore(events) private val keyProvider = RecordingKeyProvider(events) private val refresher = RecordingRefresher(events) - private fun enroller(): DeviceEnroller = + private fun enroller(withRecovery: Boolean = true): DeviceEnroller = DeviceEnroller( client = DeviceEnrollmentClient(BASE, transport), certStore = certStore, @@ -85,6 +62,7 @@ class DeviceEnrollerTest { keyAlias = ALIAS, keyProvider = keyProvider, cacheRefresher = refresher, + recoveryClient = if (withRecovery) DeviceEnrollmentClient(BASE, recoveryTransport) else null, ) // ── enroll: commit sequencing (the security-critical invariant) ──────────────────────────── @@ -221,6 +199,166 @@ class DeviceEnrollerTest { assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit") } + @Test + fun enrollPersistsTheControlPlaneUrlSoRotationKnowsWhereToRenew() = runTest { + transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody()) + transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody()) + + enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel") + + // Silent rotation happens with no UI, so the control plane the device enrolled against must be + // persisted — a rotation that fell back to a compiled-in default would renew somewhere else. + assertEquals(BASE, recordStore.saved!!.controlPlaneUrl) + } + + @Test + fun renewDecodesTheRealReissueResponseThatCarriesNoDeviceId() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + // The real /device/:id/renew 201 body — {cert, caChain, notAfter} with NO deviceId. + transport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = RotationTestFixtures.reissueBody(), + ) + + val summary = enroller().renew() + + assertEquals(LEAF_CN, summary.subjectCommonName) + assertEquals("dev-1", recordStore.saved!!.deviceId, "the device id survives the re-issue") + assertEquals(BASE, recordStore.saved!!.controlPlaneUrl) + } + + // ── recover: the EXPIRED-leaf escape hatch (plain HTTPS, cert in the body) ────────────────── + + @Test + fun recoverPostsTheStoredLeafAndAFreshCsrOnThePlainTransportNotTheMtlsOne() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(), + ) + + val summary = enroller().recover() + + // The request went out on the PLAIN transport. If it had gone on the mTLS one it would have + // presented the expired leaf and nginx would have answered a bare 400 — the production deadlock. + assertTrue(transport.recordedRequests.isEmpty(), "recovery must NEVER ride the mTLS transport") + val request = recoveryTransport.recordedRequests.single() + assertEquals("$BASE/device/dev-1/recover", request.url) + assertNull(request.headers["Authorization"], "recovery carries no bearer") + val body = request.body!!.decodeToString() + assertTrue(body.contains("\"cert\":"), "the lapsed leaf travels in the BODY") + assertTrue(body.contains(RotationTestFixtures.LEAF_B64.take(32)), "the body carries the stored leaf") + assertTrue(body.contains("\"csr\":"), "possession is proven by a fresh CSR over the same key") + assertEquals(LEAF_CN, summary.subjectCommonName) + } + + @Test + fun recoverCommitsWithTheSameSequencingAsEnrollAndRenew() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(), + ) + + enroller().recover() + + assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"), "record before the pointer flip") + assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh after the commit") + } + + @Test + fun recoverReusesTheSameHardwareKeyAndNeverGeneratesANewOne() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 201, body = RotationTestFixtures.reissueBody(), + ) + + enroller().recover() + + // The server enforces `CSR key == registered key`; generating a key here would guarantee a 403. + assertTrue(keyProvider.generatedAliases.isEmpty(), "recovery re-CSRs from the EXISTING key") + assertTrue(keyProvider.deletedAliases.isEmpty(), "a successful recovery deletes nothing") + } + + @Test + fun recoverThrowsWhenNoPlainRecoveryClientIsConfigured() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + + val error = runCatching { enroller(withRecovery = false).recover() }.exceptionOrNull() + + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(recoveryTransport.recordedRequests.isEmpty()) + assertTrue(transport.recordedRequests.isEmpty(), "and it must not silently fall back to mTLS") + } + + @Test + fun recoverThrowsWhenThereIsNoStoredLeafToPresent() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + // No cert in the store → nothing to put in the body → a fresh enroll is the only way out. + val error = runCatching { enroller().recover() }.exceptionOrNull() + + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(recoveryTransport.recordedRequests.isEmpty(), "no pointless round-trip") + } + + @Test + fun recoverThrowsWhenNothingIsEnrolled() = runTest { + val error = runCatching { enroller().recover() }.exceptionOrNull() + assertTrue(error is DeviceEnroller.EnrollmentStateException) + assertTrue(recoveryTransport.recordedRequests.isEmpty()) + } + + @Test + fun aFailedRecoveryLeavesThePriorIdentityUntouched() = runTest { + val prior = storedIdentity() + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(prior) + // Beyond the 30-day grace the control plane answers a uniform 401. + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 401, body = """{"error":"rejected"}""".toByteArray(), + ) + + val error = runCatching { enroller().recover() }.exceptionOrNull() + + assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error) + assertSame(prior, certStore.load(), "a failed recovery must not touch the live cert pointer") + assertFalse(events.contains("cert.save"), "no pointer flip on failure") + assertFalse(events.contains("cache.refresh"), "and nothing is published to the in-memory cache") + assertTrue(keyProvider.deletedAliases.isEmpty(), "the key that still works is NEVER deleted") + } + + @Test + fun theRecoveryFailureNeverCarriesTheCertificateBytes() = runTest { + recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, BASE)) + keyProvider.seed(ALIAS, softwareKey(ALIAS)) + certStore.seed(storedIdentity()) + recoveryTransport.queueSuccess( + HttpMethod.POST, "$BASE/device/dev-1/recover", 403, body = """{"error":"rejected"}""".toByteArray(), + ) + + val error = runCatching { enroller().recover() }.exceptionOrNull()!! + + val rendered = "${error.message} $error" + assertFalse(rendered.contains(RotationTestFixtures.LEAF_B64.take(32)), "no cert bytes in the error") + assertFalse(rendered.contains("MII"), "no DER/PEM material in a loggable rendering") + } + + private fun storedIdentity(): StoredIdentityMetadata = + StoredIdentityMetadata( + alias = ALIAS, + keyAlgorithm = "EC", + keyStoreAlias = ALIAS, + certificateChain = listOf(RotationTestFixtures.leafCertificate(), RotationTestFixtures.caCertificate()), + ) + // ── remove: full teardown ────────────────────────────────────────────────────────────────── @Test @@ -240,17 +378,25 @@ class DeviceEnrollerTest { private class RecordingCertStore(private val events: MutableList) : CertStore { var saved: StoredIdentityMetadata? = null var cleared = false + private var current: StoredIdentityMetadata? = null + + /** Pre-existing on-disk identity (the lapsed leaf the recovery path must present). */ + fun seed(metadata: StoredIdentityMetadata) { + current = metadata + } override fun save(metadata: StoredIdentityMetadata) { saved = metadata + current = metadata events += "cert.save" } - override fun load(): StoredIdentityMetadata? = saved + override fun load(): StoredIdentityMetadata? = current override fun clear() { cleared = true saved = null + current = null events += "cert.clear" } } diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStoreTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStoreTest.kt new file mode 100644 index 0000000..777cf0a --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/DeviceRotationStoreTest.kt @@ -0,0 +1,166 @@ +package wang.yaojia.webterm.tlsandroid + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.IOException +import java.time.Duration +import java.time.Instant + +/** + * [DeviceRotationStore] is the read side of silent rotation: it composes the two persisted stores into + * the timing snapshot the pure `RotationPolicy` consumes. The load-bearing behaviour is that the + * timing comes from the LIVE LEAF CERTIFICATE, not from the enrollment record — a record written by a + * renew/recover commit has no `renewAfter` at all (the server does not return one), so a store that + * trusted the record would rotate once and then report "not due" until the cert died. + */ +class DeviceRotationStoreTest { + private companion object { + const val ALIAS = "test-device-key" + const val CP_URL = "https://cp.terminal.yaojia.wang" + } + + private val certStore = FakeCertStore() + private val recordStore = FakeRecordStore() + private val store = DeviceRotationStore(certStore, recordStore) + + /** JUnit5's `assertNotNull` returns Unit, so assert-and-unwrap in one place. */ + private fun readSnapshot(): DeviceRotationSnapshot { + val snapshot = store.read() + assertNotNull(snapshot, "expected an enrolled rotation snapshot") + return snapshot!! + } + + private fun seedEnrolled( + controlPlaneUrl: String = CP_URL, + renewAfterEpochSeconds: Long = 0L, + ) { + recordStore.current = EnrollmentRecord( + deviceId = "dev-1", + deviceName = "Alice Pixel", + keyStoreAlias = ALIAS, + renewAfterEpochSeconds = renewAfterEpochSeconds, + controlPlaneUrl = controlPlaneUrl, + ) + certStore.current = StoredIdentityMetadata( + alias = ALIAS, + keyAlgorithm = "EC", + keyStoreAlias = ALIAS, + certificateChain = listOf(RotationTestFixtures.leafCertificate(), RotationTestFixtures.caCertificate()), + ) + } + + @Test + fun readReturnsNullWhenNothingIsEnrolled() { + assertNull(store.read(), "a fresh install has nothing to rotate") + } + + @Test + fun readReturnsNullWhenTheCertLivePointerIsAbsent() { + recordStore.current = EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, 0L, CP_URL) + // A record with no live cert cannot be renewed OR recovered (recovery needs the lapsed leaf to + // put in the body), so this is "nothing to rotate", not a failure. + assertNull(store.read()) + } + + @Test + fun readTakesTheExpiryFromTheLeafCertificate() { + seedEnrolled() + val snapshot = readSnapshot() + assertEquals("dev-1", snapshot.renewalState.deviceId) + assertEquals( + Instant.parse(RotationTestFixtures.LEAF_NOT_AFTER), + snapshot.renewalState.notAfter, + "notAfter is the leaf's own hard expiry — the value the TLS stack actually enforces", + ) + } + + @Test + fun readDerivesRenewAfterFromTheLeafAndIgnoresTheStaleRecordValue() { + // A record committed by a renew carries renewAfterEpochSeconds = 0 (unknown) because the + // renew 201 has no renewAfter; a record committed by an enroll carries a value that is stale + // the moment the leaf is replaced. Either way the LEAF is the source of truth. + seedEnrolled(renewAfterEpochSeconds = Instant.parse("1999-01-01T00:00:00Z").epochSecond) + val notBefore = Instant.parse(RotationTestFixtures.LEAF_NOT_BEFORE) + val notAfter = Instant.parse(RotationTestFixtures.LEAF_NOT_AFTER) + // The control plane's own formula: notBefore + 2/3 · lifetime. + val expected = notBefore.plus(Duration.between(notBefore, notAfter).multipliedBy(2).dividedBy(3)) + + val snapshot = readSnapshot() + + assertEquals(expected, snapshot.renewalState.renewAfter) + assertTrue(snapshot.renewalState.renewAfter!!.isAfter(notBefore)) + assertTrue(snapshot.renewalState.renewAfter!!.isBefore(notAfter)) + } + + @Test + fun readCarriesTheControlPlaneUrlSoRotationTargetsTheRightHost() { + seedEnrolled(controlPlaneUrl = "https://cp.example.test") + assertEquals("https://cp.example.test", readSnapshot().controlPlaneUrl) + } + + @Test + fun readReportsABlankControlPlaneUrlRatherThanGuessingOne() { + // A record written before the URL was persisted decodes to "". Renewing against a DEFAULT host + // would silently talk to the wrong control plane, so the blank is surfaced verbatim and the + // caller must refuse to rotate. + seedEnrolled(controlPlaneUrl = "") + assertEquals("", readSnapshot().controlPlaneUrl) + } + + @Test + fun readPropagatesAStoreFaultInsteadOfReportingNotEnrolled() { + seedEnrolled() + certStore.fault = IOException("tink blob unreadable") + // Degrading a decrypt fault to "nothing enrolled" would silently disable rotation forever; the + // caller surfaces it as a failure instead. + assertTrue(runCatching { store.read() }.exceptionOrNull() is IOException) + } + + @Test + fun theSnapshotCarriesNoCredentialInItsToString() { + seedEnrolled() + val rendered = readSnapshot().toString() + assertTrue(rendered.contains("dev-1")) + assertTrue(rendered.contains("cp.terminal.yaojia.wang")) + assertFalse(rendered.contains("MIIB"), "no certificate DER may reach a loggable rendering") + assertFalse(rendered.contains(RotationTestFixtures.LEAF_B64), "no leaf bytes in the snapshot") + } + + // ── doubles ──────────────────────────────────────────────────────────────────────────────── + + private class FakeCertStore : CertStore { + var current: StoredIdentityMetadata? = null + var fault: Exception? = null + + override fun save(metadata: StoredIdentityMetadata) { + current = metadata + } + + override fun load(): StoredIdentityMetadata? { + fault?.let { throw it } + return current + } + + override fun clear() { + current = null + } + } + + private class FakeRecordStore : EnrollmentRecordStore { + var current: EnrollmentRecord? = null + + override fun save(record: EnrollmentRecord) { + current = record + } + + override fun load(): EnrollmentRecord? = current + + override fun clear() { + current = null + } + } +} diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordCodecTest.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordCodecTest.kt new file mode 100644 index 0000000..12903d6 --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/EnrollmentRecordCodecTest.kt @@ -0,0 +1,53 @@ +package wang.yaojia.webterm.tlsandroid + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream + +/** + * [EnrollmentRecordCodec] round-trip, plus the one compatibility rule that matters: a blob written + * BEFORE `controlPlaneUrl` existed must still decode (to a blank URL) rather than being reported as + * corrupt — a corrupt-blob verdict on an existing enrollment would strand a working device. + */ +class EnrollmentRecordCodecTest { + + @Test + fun roundTripsEveryField() { + val record = EnrollmentRecord( + deviceId = "dev-1", + deviceName = "Alice Pixel", + keyStoreAlias = "webterm-device-key", + renewAfterEpochSeconds = 1_790_000_000L, + controlPlaneUrl = "https://cp.terminal.yaojia.wang", + ) + assertEquals(record, EnrollmentRecordCodec.decode(EnrollmentRecordCodec.encode(record))) + } + + @Test + fun aBlobWrittenBeforeTheControlPlaneUrlFieldDecodesToABlankUrl() { + // Exactly the four-field layout the previous codec version emitted. + val legacy = ByteArrayOutputStream().also { out -> + DataOutputStream(out).use { data -> + data.writeUTF("dev-1") + data.writeUTF("Alice Pixel") + data.writeUTF("webterm-device-key") + data.writeLong(0L) + } + }.toByteArray() + + val decoded = EnrollmentRecordCodec.decode(legacy) + + assertEquals("dev-1", decoded.deviceId) + assertEquals("", decoded.controlPlaneUrl, "an absent trailing field is unknown, not corrupt") + } + + @Test + fun aTruncatedBlobIsStillReportedAsCorrupt() { + // Tolerating the ABSENT trailing field must not turn into tolerating a mangled record. + assertThrows(CorruptStoredIdentityException::class.java) { + EnrollmentRecordCodec.decode(byteArrayOf(0x00, 0x05, 0x64)) + } + } +} diff --git a/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/RotationTestFixtures.kt b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/RotationTestFixtures.kt new file mode 100644 index 0000000..7d7d36d --- /dev/null +++ b/android/client-tls-android/src/test/kotlin/wang/yaojia/webterm/tlsandroid/RotationTestFixtures.kt @@ -0,0 +1,78 @@ +package wang.yaojia.webterm.tlsandroid + +import java.security.KeyPairGenerator +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.util.Base64 + +/** + * Shared JVM test fixtures for the enroll / rotate / recover orchestration: REAL self-signed P-256 + * X.509 certificates (base64 DER) so `CertificateFactory`, `CertificateSummaryReader` and the + * rotation-timing reader parse them exactly as they parse a server-issued leaf — and a software P-256 + * key double standing in for the non-exportable AndroidKeyStore key (which needs a device). + */ +internal object RotationTestFixtures { + const val LEAF_CN: String = "t1-device" + const val CA_CN: String = "webterm-device-ca" + + /** Validity window baked into [LEAF_B64] (read back from the DER, asserted in the store tests). */ + const val LEAF_NOT_BEFORE: String = "2026-07-18T11:21:11Z" + const val LEAF_NOT_AFTER: String = "2126-06-24T11:21:11Z" + + const val LEAF_B64: String = + "MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" + + "ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" + + "WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" + + "cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" + + "HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" + + "ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" + + "cdoleWDlqcMKU" + + const val CA_B64: String = + "MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" + + "dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" + + "YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" + + "e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" + + "4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" + + "Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" + + "YloHuEAg+kngzA33m52aWtublai4L+eybg==" + + fun leafDer(): ByteArray = Base64.getDecoder().decode(LEAF_B64) + + fun leafCertificate(): X509Certificate = parse(leafDer()) + + fun caCertificate(): X509Certificate = parse(Base64.getDecoder().decode(CA_B64)) + + /** The enroll 201 body — the ONLY route that returns deviceId/notBefore/renewAfter. */ + fun enrollBody(deviceId: String = "dev-1"): ByteArray = + """ + {"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"], + "notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z", + "renewAfter":"2026-09-05T00:00:00.000Z"} + """.trimIndent().toByteArray() + + /** + * The REAL `/device/:id/renew` and `/device/:id/recover` 201 body: `{cert, caChain, notAfter}` — + * no deviceId, no notBefore, no renewAfter (control-plane/src/api/renew.ts). + */ + fun reissueBody(): ByteArray = + """ + {"cert":"$LEAF_B64","caChain":["$CA_B64"],"notAfter":"2126-06-24T11:21:11.000Z"} + """.trimIndent().toByteArray() + + fun loginBody(): ByteArray = + """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray() + + /** A software P-256 key standing in for the non-exportable AndroidKeyStore key (§7: no emulator). */ + fun softwareKey(alias: String): HardwareBackedKey { + val generator = KeyPairGenerator.getInstance("EC") + generator.initialize(ECGenParameterSpec("secp256r1")) + val pair = generator.generateKeyPair() + return HardwareBackedKey(alias, pair.private, pair.public as ECPublicKey) + } + + private fun parse(der: ByteArray): X509Certificate = + CertificateFactory.getInstance("X.509").generateCertificate(der.inputStream()) as X509Certificate +} diff --git a/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStoreTest.kt b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStoreTest.kt new file mode 100644 index 0000000..f6822a3 --- /dev/null +++ b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStoreTest.kt @@ -0,0 +1,188 @@ +package wang.yaojia.webterm.hostregistry + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID + +/** + * Instrumented (device) contract tests for [DataStoreSessionWatermarkStore] — the storage half + * of the module, which needs a real Context/file (plan §7). + * + * The load-bearing one is [survives_a_new_store_instance_over_the_same_file]: that is the + * process-death simulation this whole class exists for. The rest mirror the JVM transform tests + * at the storage boundary (garbage at rest is dropped, the cap holds, an emptied map removes the + * key instead of leaving `{}` behind). + */ +@RunWith(AndroidJUnit4::class) +class DataStoreSessionWatermarkStoreTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + /** + * A fresh, uniquely-named Preferences file per test (no cross-test bleed). [scope] is + * injectable because DataStore throws if two instances are live on ONE file: releasing the + * file means cancelling the owning scope, which is how process death is simulated below. + */ + private fun newDataStore( + name: String = "watermarks-test-${UUID.randomUUID()}", + scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + ): DataStore { + val ctx = ApplicationProvider.getApplicationContext() + return PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { ctx.preferencesDataStoreFile(name) }, + ) + } + + @Test + fun an_empty_store_loads_an_empty_map() = runBlocking { + assertTrue(DataStoreSessionWatermarkStore(newDataStore()).loadAll().isEmpty()) + } + + @Test + fun replaceAll_then_loadAll_returns_the_same_watermarks() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + + store.replaceAll(mapOf(idA to 10L, idB to 20L)) + + assertEquals(mapOf(idA to 10L, idB to 20L), store.loadAll()) + } + + @Test + fun survives_a_new_store_instance_over_the_same_file() = runBlocking { + val fileName = "watermarks-persist-${UUID.randomUUID()}" + val firstScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // replaceAll suspends until edit() has committed to disk. + DataStoreSessionWatermarkStore(newDataStore(fileName, firstScope)) + .replaceAll(mapOf(idA to 1_700_000_000_000L)) + + // Cancelling the owning scope releases the file — DataStore refuses two live instances + // on one file, and letting go of it is exactly what process death does. + firstScope.coroutineContext.job.cancelAndJoin() + + // A brand-new store over the same file = what a cold start after process death sees. + val reopened = DataStoreSessionWatermarkStore(newDataStore(fileName)) + + assertEquals(mapOf(idA to 1_700_000_000_000L), reopened.loadAll()) + } + + @Test + fun replaceAll_is_a_wholesale_swap_not_a_merge() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + store.replaceAll(mapOf(idA to 10L)) + + store.replaceAll(mapOf(idB to 20L)) + + assertEquals(mapOf(idB to 20L), store.loadAll()) + } + + @Test + fun remove_drops_one_session_and_keeps_the_rest() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + store.replaceAll(mapOf(idA to 10L, idB to 20L)) + + val remaining = store.remove(idA) + + assertEquals(mapOf(idB to 20L), remaining) + assertEquals(remaining, store.loadAll()) + } + + @Test + fun removing_an_unknown_session_is_a_no_op() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + store.replaceAll(mapOf(idA to 10L)) + + assertEquals(mapOf(idA to 10L), store.remove(idB)) + } + + @Test + fun emptying_the_map_removes_the_stored_key_instead_of_writing_an_empty_blob() = runBlocking { + val dataStore = newDataStore() + val store = DataStoreSessionWatermarkStore(dataStore) + store.replaceAll(mapOf(idA to 10L)) + + store.remove(idA) + + assertNull(dataStore.data.first()[stringPreferencesKey(WATERMARKS_KEY_NAME)]) + } + + @Test + fun garbage_stored_at_rest_reads_back_as_no_watermarks() = runBlocking { + val dataStore = newDataStore() + dataStore.edit { it[stringPreferencesKey(WATERMARKS_KEY_NAME)] = "{not json" } + + assertTrue(DataStoreSessionWatermarkStore(dataStore).loadAll().isEmpty()) + } + + @Test + fun a_non_v4_id_stored_at_rest_is_dropped_on_read() = runBlocking { + val dataStore = newDataStore() + // A v1 UUID: parses as a UUID (the DataStoreLastSessionStore precedent bug) but is not + // a valid wire session id, so Validation.isValidSessionId must reject it. + val v1 = "3f2504e0-4f89-11d3-9a0c-0305e82c3301" + dataStore.edit { it[stringPreferencesKey(WATERMARKS_KEY_NAME)] = """{"$idA":10,"$v1":20}""" } + + assertEquals(mapOf(idA to 10L), DataStoreSessionWatermarkStore(dataStore).loadAll()) + } + + @Test + fun the_stored_map_is_capped_so_it_cannot_grow_without_bound() = runBlocking { + val store = DataStoreSessionWatermarkStore(newDataStore()) + val oversized = (0 until MAX_WATERMARKS + 5).associate { sessionId(it) to (it + 1).toLong() } + + val stored = store.replaceAll(oversized) + + assertEquals(MAX_WATERMARKS, stored.size) + assertEquals(MAX_WATERMARKS, store.loadAll().size) + assertFalse(store.loadAll().containsKey(sessionId(0))) // oldest-seen dropped first + } + + @Test + fun the_watermark_key_is_disjoint_from_the_other_stores_over_one_file() = runBlocking { + // ONE Preferences DataStore is shared app-wide (:app StorageModule) — this is the + // regression test for "never collide with a sibling store's key". + val dataStore = newDataStore() + val hosts = DataStoreHostStore(dataStore) + val lastSession = DataStoreLastSessionStore(dataStore) + val watermarks = DataStoreSessionWatermarkStore(dataStore) + val host = Host.create(id = "host-1", name = "mac", baseUrl = "http://10.0.2.2:3000")!! + + hosts.upsert(host) + lastSession.setLastSessionId(idB, hostId = "host-1") + watermarks.replaceAll(mapOf(idA to 10L)) + + assertEquals(listOf(host), hosts.loadAll()) + assertEquals(idB, lastSession.lastSessionId("host-1")) + assertEquals(mapOf(idA to 10L), watermarks.loadAll()) + } + + /** A distinct valid v4 id per [index] (the last 4 hex digits carry the index). */ + private fun sessionId(index: Int): String = + "3f2504e0-4f89-41d3-9a0c-0305e82c%04x".format(index) + + private companion object { + /** Must match `DataStoreSessionWatermarkStore.WATERMARKS_KEY` (private there, asserted here). */ + const val WATERMARKS_KEY_NAME = "unreadWatermarks" + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStore.kt new file mode 100644 index 0000000..92b94a9 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreSessionWatermarkStore.kt @@ -0,0 +1,65 @@ +package wang.yaojia.webterm.hostregistry + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first + +/** + * Preferences-DataStore-backed [SessionWatermarkStore]. The whole watermark map is ONE JSON + * string under [WATERMARKS_KEY] ([SessionWatermarkCodec]) — the same shape [DataStoreHostStore] + * uses for `"hosts"`, and for the same reason: the cap and the sanitize pass have to apply to + * the map as a whole, which a per-session key prefix (the [DataStoreLastSessionStore] shape) + * cannot do without scanning every key on every write and still leaking orphaned keys. + * + * The key name mirrors the iOS `UserDefaults` key `"unreadWatermarks"`. It is **disjoint** from + * every other key over this file (`"hosts"`, `"lastSessionId."`, `"authCookiesSealed"`), + * because ONE Preferences DataStore instance is shared app-wide (`:app` `StorageModule`) — a + * second instance over the same file would corrupt it, so never open one. + * + * Reads decode then [forStorage] (validate + cap): the blob is untrusted at rest. Writes are an + * atomic read-modify-write via [DataStore.edit], and an empty result REMOVES the key rather than + * writing `{}`, so the file shrinks back to nothing. + * + * The [DataStore] is injected (constructed from a Context in `:app` DI) so this class has no + * Android-framework surface of its own; the on-device behaviour — including survival across a + * fresh store instance, i.e. process death — is verified instrumented (androidTest, plan §7). + * Not encrypted, deliberately: see the posture note on [SessionWatermarkStore]. + */ +public class DataStoreSessionWatermarkStore( + private val dataStore: DataStore, +) : SessionWatermarkStore { + + override suspend fun loadAll(): Map = + SessionWatermarkCodec.decode(dataStore.data.first()[WATERMARKS_KEY]).forStorage() + + override suspend fun replaceAll(watermarks: Map): Map = + writeTransform { watermarks.forStorage() } + + override suspend fun remove(sessionId: String): Map = + writeTransform { it.removingWatermark(sessionId) } + + private suspend fun writeTransform( + transform: (Map) -> Map, + ): Map { + lateinit var updated: Map + dataStore.edit { prefs -> + // Decoding through forStorage() means every write also garbage-collects invalid + // rows and re-applies the cap, even when the transform itself only removes. + val current = SessionWatermarkCodec.decode(prefs[WATERMARKS_KEY]).forStorage() + updated = transform(current) + if (updated.isEmpty()) { + prefs.remove(WATERMARKS_KEY) + } else { + prefs[WATERMARKS_KEY] = SessionWatermarkCodec.encode(updated) + } + } + return updated + } + + private companion object { + /** The one stored value: `SessionWatermarkCodec.encode(watermarks)`. */ + val WATERMARKS_KEY = stringPreferencesKey("unreadWatermarks") + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStore.kt new file mode 100644 index 0000000..1d18f6c --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStore.kt @@ -0,0 +1,40 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * In-memory [SessionWatermarkStore]. Lives in `main` (not `test`) on purpose — it stands in for + * the DataStore store in this module's contract tests AND in the AW4 ViewModel tests, exactly + * like [InMemoryHostStore] / [InMemoryAuthCookieStore]. + * + * It applies the SAME [forStorage] rules (validation + cap) as [DataStoreSessionWatermarkStore]; + * a laxer double would let a ViewModel test pass against behaviour no device exhibits. + * + * A [Mutex] serialises each read-modify-write; the state is a value snapshot replaced wholesale + * on every change (no in-place mutation of a shared reference). + */ +public class InMemorySessionWatermarkStore( + initial: Map = emptyMap(), +) : SessionWatermarkStore { + private val mutex = Mutex() + + // forStorage() always builds a fresh map, so a mutable map handed to the ctor cannot alias state. + private var stored: Map = initial.forStorage() + + override suspend fun loadAll(): Map = mutex.withLock { stored } + + override suspend fun replaceAll(watermarks: Map): Map = + write { watermarks.forStorage() } + + override suspend fun remove(sessionId: String): Map = + write { it.removingWatermark(sessionId) } + + private suspend fun write( + transform: (Map) -> Map, + ): Map = mutex.withLock { + val updated = transform(stored) + stored = updated + updated + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodec.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodec.kt new file mode 100644 index 0000000..4321655 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodec.kt @@ -0,0 +1,33 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Pure JSON codec for the persisted watermark map (shared by [DataStoreSessionWatermarkStore], + * JVM-testable without a Context). Mirrors [HostCodec]: encode is total, decode is defensive — + * a corrupt/undecodable blob reads back as "no watermarks" so a cold start can never crash on + * a bad row. + * + * Deliberately dumb about *meaning*: it does not validate session ids or instants. That is + * [sanitizedWatermarks]' job, so exactly one place owns the trust decision (and the in-memory + * double gets the identical treatment without touching JSON). + */ +internal object SessionWatermarkCodec { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun encode(watermarks: Map): String = json.encodeToString(watermarks) + + fun decode(raw: String?): Map { + if (raw.isNullOrBlank()) return emptyMap() + return try { + json.decodeFromString>(raw) + } catch (_: Exception) { + emptyMap() // corrupted blob at rest → start clean, never crash + } + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkStore.kt new file mode 100644 index 0000000..2e429fa --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkStore.kt @@ -0,0 +1,151 @@ +package wang.yaojia.webterm.hostregistry + +import wang.yaojia.webterm.wire.Validation + +/** + * Durable storage for the unread **watermarks** — `sessionId → last-seen instant (ms since + * epoch)` — that decide the session-list unread dot. Ports the iOS + * `UnreadWatermarkStore` / `UserDefaultsUnreadWatermarkStore` pair (plan §2 lists "unread + * watermarks" under *Persistence (non-secret)*). + * + * Implementations: [DataStoreSessionWatermarkStore] (real, Preferences-DataStore-backed) and + * [InMemorySessionWatermarkStore] (in-`main` double, exactly like [InMemoryHostStore] / + * [InMemoryAuthCookieStore]). + * + * ## Why this exists at all + * Without it the dot resets whenever the process dies — which is precisely when it matters: + * the product premise is walking away and coming back, and a dot that only survives while + * the app is warm answers a question the user never asked. + * + * ## Division of labour with `UnreadLedger` (do not duplicate it) + * The *reducer* — monotonic `record`, the `isUnread` comparison, the tie-broken cap — lives + * in `:session-core`'s `UnreadLedger`, which is deliberately persistence-agnostic. This + * module cannot see it (`:host-registry` depends only on `:wire-protocol`; nothing points + * sideways — see `android/README.md`), and must not restate it. So the contract here is + * plain-map I/O, like the iOS store's `[UUID: Int]`: `:app` owns the ledger, hands its + * `watermarks` map to [replaceAll], and rebuilds a ledger from [loadAll] on cold start. + * + * ## At-rest posture — no Tink here, deliberately + * The neighbouring [AuthCookieStore] blob IS Tink-AEAD-sealed, so a reader will reasonably + * ask why this one is not. Because it is not a credential and not private data: a watermark + * is a UUID the device already stores in the clear (the host list, the last-session id) plus + * a coarse timestamp of when the user last *looked* at a session. Sealing it would buy + * nothing an attacker with app-private read access does not already have, while adding a + * fail-closed dependency on `AndroidKeyStore` — i.e. a keystore hiccup would silently reset + * unread state. Plan §2's split is explicit: secrets get the Tink/AndroidKeyStore tier, + * non-secret UI state gets plain Preferences DataStore. This is the second tier, same as + * [HostStore] and [LastSessionStore]. (`android:allowBackup="false"` still applies to the + * whole file.) + * + * ## Untrusted at rest + * Persisted ids are re-validated on every read with the frozen + * [Validation.isValidSessionId] — the SAME v4-specific guard the wire codec applies to + * server-issued ids, **not** a format-only `UUID.fromString` (that mistake was already made + * once in [DataStoreLastSessionStore] and had to be corrected; do not repeat it). + * + * Immutable style throughout, like [HostStore]: mutations RETURN the new map instead of + * mutating shared state, an unknown id is an explicit no-op, and encounter order is + * preserved (only the cap may reorder — see [cappedWatermarks]). + */ +public interface SessionWatermarkStore { + /** + * Every stored watermark, already sanitized and capped ([forStorage]). Empty when + * nothing is stored or the blob no longer decodes — never throws. + */ + public suspend fun loadAll(): Map + + /** + * Swap the whole map for [watermarks] — the write `:app` drives after a `markSeen` + * ("this is the ledger now"). A wholesale swap, NOT a merge: the in-memory `UnreadLedger` + * is the source of truth, so a merge here could resurrect an entry the ledger dropped. + * Returns exactly what was stored (i.e. [watermarks].[forStorage]), which may be a + * subset of the argument. + */ + public suspend fun replaceAll(watermarks: Map): Map + + /** + * Forget [sessionId]'s watermark — the eager GC hook for a session that ended (`exit` + * frame / a kill), matching how [LastSessionStore] is cleared on `.exited`. An unknown + * or malformed id is an explicit no-op returning the unchanged map, never a throw. + */ + public suspend fun remove(sessionId: String): Map +} + +// ── Pure immutable transforms shared by every SessionWatermarkStore ─────────────────────── +// (DRY, mirrors HostStore.kt / AuthCookieStore.kt). Never mutate the receiver — always +// return a fresh map. `internal` so both stores and the same-module JVM tests can use them +// without widening the public surface. + +/** + * Hard ceiling on stored watermarks. **This is what bounds growth** (see the GC note below) + * and mirrors `UnreadLedger.MAX_ENTRIES` in `:session-core`, which cannot be imported here + * (no sideways module edge). Keep the two numbers equal: the ledger caps what `:app` holds, + * this caps what reaches the disk, and a store that trusted the caller would be no bound at + * all. + * + * ### GC rule (the decision, stated explicitly) + * Sessions are ephemeral, so a watermark can outlive its session forever. Growth is bounded + * by **three** rules, in order of preference: + * 1. **Eager** — [SessionWatermarkStore.remove] on a session that exits or is killed. This + * is the only rule that frees an entry the moment it is provably dead. + * 2. **Structural** — this count cap. Over [MAX_WATERMARKS], the OLDEST-seen entries are + * dropped ([cappedWatermarks]), so the file can never exceed ~512 · ~50 B ≈ 25 KB no + * matter how many sessions a device sees or how many exits it misses (force-stop, crash, + * a host killed from another device). This is the backstop that actually guarantees the + * bound. + * 3. **Hygiene** — [sanitizedWatermarks] drops rows that no longer validate, so a format + * change or a corrupted row cannot accumulate either. + * + * Deliberately NOT a rule: pruning to the ids returned by the latest `GET /live-sessions`. + * That looks tempting and is wrong — the fetch covers ONE host, so it would wipe every other + * host's watermarks on a host switch, and an offline/erroring host returns nothing at all + * (iOS records the same decision in `SessionListViewModel`). + * + * Also deliberately NOT a rule: a staleness/TTL sweep. Dropping an old watermark re-lights + * the dot for a session that is still alive and still unchanged — a *false* unread, which is + * worse than 25 KB. + */ +internal const val MAX_WATERMARKS: Int = 512 + +/** + * Drop everything untrustworthy: ids that are not valid wire session ids + * ([Validation.isValidSessionId]) and non-positive instants (`<= 0` means "never seen", which + * is already the default for a missing entry — storing it is pure waste). Valid ids are + * canonicalised to lower case and case-variant spellings of the same id collapse to their + * newest instant (they are the same session; `Validation` is case-insensitive, `UUID.toString` + * is lower case, and merging by max can never lower a watermark). Encounter order is kept. + */ +internal fun Map.sanitizedWatermarks(): Map { + val out = LinkedHashMap(size) + for ((id, atMs) in this) { + if (atMs <= 0L || !Validation.isValidSessionId(id)) continue + val key = id.lowercase() + val existing = out[key] + out[key] = if (existing == null) atMs else maxOf(existing, atMs) + } + return out +} + +/** + * Keep at most the [MAX_WATERMARKS] NEWEST watermarks, tie-broken by session id so the + * outcome is deterministic across runs (same rule as `UnreadLedger.capped`). Under the cap + * the receiver's own order is returned untouched; over it the result is newest-first, since a + * map that had to be truncated has no meaningful insertion order left to preserve. + */ +internal fun Map.cappedWatermarks(): Map { + if (size <= MAX_WATERMARKS) return this + return entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .take(MAX_WATERMARKS) + .associate { it.key to it.value } +} + +/** A new map without [sessionId]'s watermark (case-insensitive). Unknown id → unchanged contents. */ +internal fun Map.removingWatermark(sessionId: String): Map { + val target = sessionId.lowercase() + return filterKeys { it.lowercase() != target } +} + +/** The single write/read gate: sanitize, then cap. Everything crossing storage goes through it. */ +internal fun Map.forStorage(): Map = + sanitizedWatermarks().cappedWatermarks() diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStoreTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStoreTest.kt new file mode 100644 index 0000000..056ae0f --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemorySessionWatermarkStoreTest.kt @@ -0,0 +1,77 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Contract tests for the in-`main` [InMemorySessionWatermarkStore] double (the same role + * [InMemoryHostStore] / [InMemoryAuthCookieStore] play: it stands in for the DataStore + * store in the AW4 ViewModel tests). The double must enforce the SAME at-rest rules as + * the real store, otherwise a ViewModel test can pass against behaviour the device never + * exhibits. + */ +class InMemorySessionWatermarkStoreTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + @Test + fun `an empty store loads an empty map`() = runTest { + assertTrue(InMemorySessionWatermarkStore().loadAll().isEmpty()) + } + + @Test + fun `replaceAll then loadAll returns the same watermarks`() = runTest { + val store = InMemorySessionWatermarkStore() + + store.replaceAll(mapOf(idA to 10L, idB to 20L)) + + assertEquals(mapOf(idA to 10L, idB to 20L), store.loadAll()) + } + + @Test + fun `replaceAll returns exactly what was stored`() = runTest { + val store = InMemorySessionWatermarkStore() + + val stored = store.replaceAll(mapOf(idA to 10L, "garbage" to 20L)) + + assertEquals(mapOf(idA to 10L), stored) + assertEquals(stored, store.loadAll()) + } + + @Test + fun `replaceAll is a wholesale swap, not a merge`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L)) + + store.replaceAll(mapOf(idB to 20L)) + + assertEquals(mapOf(idB to 20L), store.loadAll()) + } + + @Test + fun `an initial map is sanitized too`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L, "garbage" to 20L)) + + assertEquals(mapOf(idA to 10L), store.loadAll()) + } + + @Test + fun `remove drops one session and returns the new map`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L, idB to 20L)) + + val remaining = store.remove(idA) + + assertEquals(mapOf(idB to 20L), remaining) + assertEquals(remaining, store.loadAll()) + } + + @Test + fun `removing an unknown session is a no-op`() = runTest { + val store = InMemorySessionWatermarkStore(mapOf(idA to 10L)) + + assertEquals(mapOf(idA to 10L), store.remove(idB)) + assertEquals(mapOf(idA to 10L), store.loadAll()) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodecTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodecTest.kt new file mode 100644 index 0000000..5c2c366 --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkCodecTest.kt @@ -0,0 +1,60 @@ +package wang.yaojia.webterm.hostregistry + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * At-rest codec tests (mirrors `HostCodecTest`): encode is total, decode is defensive — + * the blob is untrusted at rest, so anything undecodable reads back as "no watermarks" + * rather than crashing the session list on cold start. + */ +class SessionWatermarkCodecTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + @Test + fun `round-trips a watermark map`() { + val map = mapOf(idA to 1_700_000_000_000L, idB to 42L) + + assertEquals(map, SessionWatermarkCodec.decode(SessionWatermarkCodec.encode(map))) + } + + @Test + fun `an empty map round-trips to an empty map`() { + assertTrue(SessionWatermarkCodec.decode(SessionWatermarkCodec.encode(emptyMap())).isEmpty()) + } + + @Test + fun `null reads back as empty`() { + assertTrue(SessionWatermarkCodec.decode(null).isEmpty()) + } + + @Test + fun `blank reads back as empty`() { + assertTrue(SessionWatermarkCodec.decode(" ").isEmpty()) + } + + @Test + fun `a corrupt blob reads back as empty instead of throwing`() { + assertTrue(SessionWatermarkCodec.decode("{not json").isEmpty()) + } + + @Test + fun `a wrong-shaped blob reads back as empty`() { + // A JSON array where a map was written (e.g. a hand-edited or superseded format). + assertTrue(SessionWatermarkCodec.decode("""["$idA"]""").isEmpty()) + } + + @Test + fun `a non-numeric instant reads back as empty`() { + assertTrue(SessionWatermarkCodec.decode("""{"$idA":"yesterday"}""").isEmpty()) + } + + @Test + fun `decode does not itself validate ids - the store transforms do`() { + // Keeping the codec dumb keeps ONE place (sanitizedWatermarks) responsible for trust. + assertEquals(mapOf("garbage" to 5L), SessionWatermarkCodec.decode("""{"garbage":5}""")) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkTransformsTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkTransformsTest.kt new file mode 100644 index 0000000..d54e310 --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/SessionWatermarkTransformsTest.kt @@ -0,0 +1,150 @@ +package wang.yaojia.webterm.hostregistry + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Pure-transform tests for the watermark map shared by [InMemorySessionWatermarkStore] and + * [DataStoreSessionWatermarkStore] (mirrors `HostStoreTransformsTest` / + * `AuthCookieStoreTransformsTest`). These are the JVM-testable half of the storage split + * (plan §3: "the logic half of :host-registry" is in the Kover gate). + * + * Covers the three at-rest defences and the GC bound: + * - every stored session id is re-validated with the frozen `Validation.isValidSessionId`; + * - non-positive instants are dropped (a watermark of 0 means "never seen" anyway); + * - case-variant spellings of one id collapse via max (they are the same session); + * - the map is capped at [MAX_WATERMARKS], newest kept — what bounds growth. + */ +class SessionWatermarkTransformsTest { + + private val idA = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val idB = "3f2504e0-4f89-41d3-9a0c-0305e82c3302" + + // ── sanitizedWatermarks() ───────────────────────────────────────────────── + + @Test + fun `a valid map survives sanitizing unchanged`() { + val map = mapOf(idA to 10L, idB to 20L) + + assertEquals(map, map.sanitizedWatermarks()) + } + + @Test + fun `a non-v4 session id is dropped`() { + // UUID v1 (version nibble 1) parses as a UUID but is not a valid wire session id. + val v1 = "3f2504e0-4f89-11d3-9a0c-0305e82c3301" + val map = mapOf(idA to 10L, v1 to 20L, "abc123" to 30L, "" to 40L) + + assertEquals(mapOf(idA to 10L), map.sanitizedWatermarks()) + } + + @Test + fun `non-positive instants are dropped`() { + val map = mapOf(idA to 0L, idB to -5L) + + assertTrue(map.sanitizedWatermarks().isEmpty()) + } + + @Test + fun `case-variant spellings of one id collapse to the newest`() { + val map = mapOf(idA to 10L, idA.uppercase() to 99L) + + assertEquals(mapOf(idA to 99L), map.sanitizedWatermarks()) + } + + @Test + fun `sanitizing preserves encounter order`() { + val map = linkedMapOf(idB to 20L, idA to 10L) + + assertEquals(listOf(idB, idA), map.sanitizedWatermarks().keys.toList()) + } + + @Test + fun `sanitizing never mutates the receiver`() { + val map = linkedMapOf(idA to 10L, "abc123" to 20L) + + map.sanitizedWatermarks() + + assertEquals(linkedMapOf(idA to 10L, "abc123" to 20L), map) + } + + // ── cappedWatermarks() ─────────────────────────────────────────────────── + + @Test + fun `a map at the cap is returned unchanged`() { + val map = watermarks(MAX_WATERMARKS) + + assertEquals(map, map.cappedWatermarks()) + } + + @Test + fun `over the cap the newest entries are kept`() { + val map = watermarks(MAX_WATERMARKS + 10) + + val capped = map.cappedWatermarks() + + assertEquals(MAX_WATERMARKS, capped.size) + // Instants ascend with the index, so the 10 oldest (lowest) must be the dropped ones. + assertFalse(capped.containsKey(sessionId(0))) + assertTrue(capped.containsKey(sessionId(MAX_WATERMARKS + 9))) + } + + @Test + fun `equal instants break the tie deterministically by session id`() { + val map = (0 until MAX_WATERMARKS + 2).associate { sessionId(it) to 7L } + + val capped = map.cappedWatermarks() + + assertEquals(MAX_WATERMARKS, capped.size) + assertEquals(map.cappedWatermarks().keys, capped.keys) // stable across runs + // Lowest ids win the tie-break, so the two highest ids are the dropped ones. + assertFalse(capped.containsKey(sessionId(MAX_WATERMARKS + 1))) + } + + // ── removingWatermark() ────────────────────────────────────────────────── + + @Test + fun `removing drops just that session`() { + val map = mapOf(idA to 10L, idB to 20L) + + assertEquals(mapOf(idB to 20L), map.removingWatermark(idA)) + } + + @Test + fun `removing an unknown session is a no-op`() { + val map = mapOf(idA to 10L) + + assertEquals(map, map.removingWatermark(idB)) + } + + @Test + fun `removing is case-insensitive like the id itself`() { + val map = mapOf(idA to 10L) + + assertTrue(map.removingWatermark(idA.uppercase()).isEmpty()) + } + + // ── forStorage() = sanitize + cap ──────────────────────────────────────── + + @Test + fun `forStorage sanitizes and caps in one pass`() { + val map = watermarks(MAX_WATERMARKS + 1) + mapOf("garbage" to 999_999L) + + val stored = map.forStorage() + + assertEquals(MAX_WATERMARKS, stored.size) + assertFalse(stored.containsKey("garbage")) + } + + // ── helpers ───────────────────────────────────────────────────────────── + + /** A distinct valid v4 id per [index] (the last 4 hex digits carry the index). */ + private fun sessionId(index: Int): String = + "3f2504e0-4f89-41d3-9a0c-0305e82c%04x".format(index) + + /** [count] valid watermarks whose instants ascend with the index (1-based, so all positive). */ + private fun watermarks(count: Int): Map = + (0 until count).associate { sessionId(it) to (it + 1).toLong() } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt index 472d53f..3c7f780 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalHostView.kt @@ -1,7 +1,9 @@ package wang.yaojia.webterm.terminalview import android.content.Context +import android.graphics.Canvas import android.util.TypedValue +import android.view.ActionMode import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent @@ -49,8 +51,11 @@ import kotlin.math.roundToInt * 4. **text selection** — `startTextSelectionMode` arms an `ActionMode` whose Copy * (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100) is * `mTermSession.onCopyTextToClipboard(...)` and whose Paste (offsets 126-136) is - * `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true, - * which is a **recorded deviation from plan §6.5** (see that method for the full rationale). + * `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true so + * the mode can never arm, and the press starts the **first-party** selection layer instead + * ([TerminalSelection] for the design; [beginSelectionAt] / [copySelection] here). Only the stock + * controller is replaced — the affordance is still an Android `ActionMode` over `ClipboardManager`, as + * plan §6.5 specifies. * * ### Stock-method audit (verified with `javap -p -c`; keep it current when the pinned version moves) * | stock member | reachable here? | verdict | @@ -68,6 +73,7 @@ import kotlin.math.roundToInt * | `updateSize` | no | early-returns without a session; [TerminalResizeDriver] replaces it | * | `attachSession` | no | never called — it is the process-forking path we replace | * | `startTextSelectionMode` / `TextSelectionCursorController` | **no** | long press consumed (4) | + * | `mDefaultSelectors` (the renderer's own highlight channel) | no (package-private) | our overlay draws it | * | `setTerminalCursorBlinker*` | never invoked | cosmetic gap vs stock: the cursor does not blink | * * ### The latent renderer crash this also fixes @@ -96,7 +102,10 @@ import kotlin.math.roundToInt * delegates to ([TerminalKeyDecoder], [TerminalInputBytes], [TerminalResizeDriver], [TerminalScrollGesture]) * is what the JVM tests pin. */ -public class RemoteTerminalHostView internal constructor(context: Context) : FrameLayout(context) { +public class RemoteTerminalHostView internal constructor( + context: Context, + clipboard: TerminalClipboard = SystemTerminalClipboard(context), +) : FrameLayout(context) { /** The stock Termux view. Rendering is entirely its own; we only feed it an emulator and input. */ internal val stockView: TerminalView = TerminalView(context, null) @@ -124,6 +133,11 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra val step = if (up) -1 else 1 stockView.topRow = min(NEWEST_ROW, max(-transcriptRows, stockView.topRow + step)) stockView.invalidate() + // The selection highlight is recorded in THIS view's display list and is positioned + // relative to `topRow`, so invalidating only the child would re-render scrolled glyphs + // under a stale band. Reachable with a selection alive: an external mouse wheel still + // scrolls (a selection lives in external rows, so moving the viewport is harmless). + this@RemoteTerminalHostView.invalidate() } override fun sendKeys(data: String) { @@ -134,6 +148,36 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra }, ) + // ── Text selection (the first-party replacement for the stock, crashing one) ────────────────────── + + private val selectionBuffer = TerminalEmulatorSelectionBuffer { stockView.mEmulator } + + private val selection = TerminalSelectionController(selectionBuffer, clipboard) { isActive -> + if (isActive) { + showCopyAffordance() + } else { + // A selection can be dropped mid-drag (a resize, a rebind), so the drag flag has to die with + // it — a stale "true" would make the next pinch look like an extend. + isExtendingSelection = false + hideCopyAffordance() + } + // The highlight is painted by THIS view (dispatchDraw), so the frame — not just the child — has to + // be invalidated for a span change to appear. + invalidate() + onSelectionChanged?.invoke(isActive) + } + + /** Set by [RemoteTerminalView] so `:app` can mirror the state in its own toolbar. */ + internal var onSelectionChanged: ((isActive: Boolean) -> Unit)? = null + + private val selectionPainter = TerminalSelectionPainter() + + /** The live floating toolbar, or null when nothing is selected (or the platform declined to start one). */ + private var copyAffordance: ActionMode? = null + + /** True between the first extend MOVE and the lift, so the UP of a drag we own is reported as ours. */ + private var isExtendingSelection = false + init { addView(stockView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) // Builds TerminalRenderer → onDraw is safe and the cell metrics become readable. @@ -159,13 +203,140 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra internal fun bindEmulator(emulator: TerminalEmulator) { stockView.mEmulator = emulator // A new emulator means a new transcript: drop any half-finished drag rather than let it resolve - // rows against a buffer it did not start in. + // rows against a buffer it did not start in, and drop any selection for the same reason — its rows + // name content that no longer exists. scrollGesture.reset() + selection.clear() } - /** Invalidate/redraw the stock view for the current emulator state (main thread). */ + /** + * Invalidate/redraw the stock view for the current emulator state (main thread), keeping a live + * selection pointing at the text it was made on. + * + * `TerminalView.onScreenUpdated` maintains a selection across incoming output — it shifts `mTopRow` by + * `getScrollCounter()` and decrements its selection cursors — but only when `isSelectingText()` is true, + * which for us is permanently false (we never arm the stock controller). What it does instead is reset + * `mTopRow` to 0 and CLEAR the scroll counter, so both halves of that bookkeeping have to happen here: + * read the counter first (nothing else can, afterwards), then re-pin the viewport and shift the span. + * Skipping it would leave the user's highlight over text that scrolled away — and Copy would then put + * something the user never selected on the clipboard. + */ internal fun requestScreenUpdate() { + val isSelecting = selection.isActive + val scrolledRows = if (isSelecting) stockView.mEmulator?.scrollCounter ?: NO_SCROLL else NO_SCROLL + val pinnedTopRow = if (isSelecting) stockView.topRow - scrolledRows else null + stockView.onScreenUpdated() + + if (pinnedTopRow != null) { + val oldestRow = -(stockView.mEmulator?.screen?.activeTranscriptRows ?: 0) + stockView.topRow = pinnedTopRow.coerceIn(oldestRow, NEWEST_ROW) + } + selection.onContentScrolled(scrolledRows) + invalidate() + } + + // ── Text-selection API (used by [RemoteTerminalView], which exposes it to `:app`) ────────────────── + + internal val isSelectingText: Boolean get() = selection.isActive + + /** + * Start a selection at these view pixels, snapped to the word under them. Answering false means there + * was no measured grid to resolve the pixels against, so the press is ignored. + */ + internal fun beginSelectionAt(xPx: Float, yPx: Float): Boolean { + val cell = cellAt(xPx, yPx) ?: return false + if (!selection.beginAt(cell)) return false + // `getScrollCounter()` accumulates until something clears it, and the only thing that does is + // `onScreenUpdated()`. A multi-chunk append posts one screen update for several main-thread appends, + // so a long press can land between a scroll and its update — and those rows scrolled BEFORE this + // selection existed. Counting them would shift the brand-new span by rows it never saw (measured: a + // selection two rows of stale counter old landed on the wrong line). Zeroing here makes the shift in + // [requestScreenUpdate] mean exactly "scrolled since the selection was made"; nothing else in the + // pinned artifact reads the counter (stock only reads it inside `isSelectingText()`, always false). + stockView.mEmulator?.clearScrollCounter() + return true + } + + /** THE explicit copy (plan §8): nothing else in this module writes terminal text to the clipboard. */ + internal fun copySelection(): TerminalCopyResult = selection.copy() + + internal fun clearSelection() { + selection.clear() + } + + /** The text currently highlighted — for tests and for `:app` to preview; reads the buffer, mutates nothing. */ + internal fun selectedText(): String = selection.selectedText() + + /** The highlight bands for the current selection, in this frame's pixel space. */ + internal fun highlightRects(): List { + val span = selection.selection?.span ?: return emptyList() + val bounds = selectionBuffer.bounds() ?: return emptyList() + val metrics = cellMetrics() ?: return emptyList() + return TerminalSelectionGeometry.highlightRects(span, bounds, metrics, stockView.topRow) + } + + /** + * A tap: dismiss a live selection, otherwise raise the soft keyboard. + * + * Dismissing takes precedence because a selection makes every drag an extend — the tap is how the user + * gets scrolling back, and it is the platform-standard way to end a selection. + */ + internal fun onTapped() { + if (selection.isActive) { + selection.clear() + return + } + showSoftKeyboard() + } + + private fun cellAt(xPx: Float, yPx: Float): TerminalCell? { + val metrics = cellMetrics() ?: return null + return TerminalSelectionGeometry.cellAt(xPx, yPx, metrics, stockView.topRow) + } + + private fun showCopyAffordance() { + val existing = copyAffordance + if (existing != null) { + // The span moved: let the floating toolbar re-anchor rather than stacking a second mode. + existing.invalidateContentRect() + return + } + copyAffordance = startActionMode( + TerminalSelectionActionMode( + contentRect = { highlightRects().firstOrNull() }, + onCopy = { + copySelection() + selection.clear() + }, + onDismissed = { + copyAffordance = null + selection.clear() + }, + ), + ActionMode.TYPE_FLOATING, + ) + } + + private fun hideCopyAffordance() { + // Null the field BEFORE finishing: finish() calls onDestroyActionMode, which calls back into + // selection.clear(). The controller's own "already clear" guard stops the recursion; this stops a + // second finish() on a dead mode. + val mode = copyAffordance ?: return + copyAffordance = null + mode.finish() + } + + /** + * Draw the children (the stock view paints every glyph) and then the selection highlight ON TOP. + * + * An overlay, not a renderer patch: plan §6.1 keeps rendering stock, and the renderer's own selection + * channel (`TerminalView.mDefaultSelectors`) is package-private to `com.termux.view` — see + * [TerminalSelectionPainter] for why reaching it was rejected. + */ + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + selectionPainter.draw(canvas, highlightRects()) } /** @@ -287,12 +458,29 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra return super.dispatchGenericMotionEvent(event) } - override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + /** + * Widened to public (the `View` callback is `protected`) so the size-change behaviour — including the + * selection drop below — is driven directly by a JVM test, exactly like the other overrides on this + * frame. Nothing in production calls it; the platform does. + */ + public override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { super.onSizeChanged(width, height, oldWidth, oldHeight) + // A new size means a new grid and an emulator reflow, so the cells the span names are about to hold + // different text. Dropping the selection is the honest response — re-mapping it across a reflow is + // guesswork, and keeping it would let Copy return text the user never highlighted. + selection.clear() sink?.onViewSize(width, height) } + /** + * With a live selection every drag EXTENDS it; otherwise the drag goes to the scroll gesture. + * + * The branch is what makes the feature usable on a touch screen: without it the very drag that should + * grow the selection would instead be claimed by [TerminalScrollGesture] and — inside an + * alternate-screen TUI — typed into the shell as arrow keys. + */ private fun routeTouch(event: MotionEvent): Boolean { + if (selection.isActive) return routeSelectionTouch(event) eventInFlight = event try { return scrollGesture.onTouch(TouchFacts.of(event)) @@ -301,6 +489,35 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra } } + private fun routeSelectionTouch(event: MotionEvent): Boolean = when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + // A second finger is a pinch, which belongs to the stock ScaleGestureDetector (safe: no session + // deref) and is where our onScale veto is enforced. + if (event.pointerCount > SINGLE_POINTER) { + isExtendingSelection + } else { + isExtendingSelection = true + extendSelectionTo(event.x, event.y) + true + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + val wasExtending = isExtendingSelection + isExtendingSelection = false + wasExtending + } + + // ACTION_DOWN is deliberately NOT claimed: the stock gesture detector has to keep seeing it so it + // can still resolve the gesture into a tap (which dismisses the selection) or another long press + // (which re-anchors it). Claiming it would make both impossible. + else -> false + } + + private fun extendSelectionTo(xPx: Float, yPx: Float) { + cellAt(xPx, yPx)?.let(selection::extendTo) + } + /** * Stock `sendMouseEventCode(event, MOUSE_WHEEL{UP,DOWN}_BUTTON, true)`, offsets 0-100: report the * wheel at the cell the gesture started on (one anchor per down-time) so a program tracking the mouse @@ -349,6 +566,12 @@ public class RemoteTerminalHostView internal constructor(context: Context) : Fra /** No wheel gesture has been anchored yet (stock initialises `mMouseStartDownTime` to -1). */ const val NO_DOWN_TIME: Long = -1L + /** Nothing scrolled off the top since the last screen update. */ + const val NO_SCROLL: Int = 0 + + /** A second pointer means a pinch, not a drag. */ + const val SINGLE_POINTER: Int = 1 + /** `getColumnAndRow` returns `[column, row]`, both 0-based; the wire protocol is 1-based. */ const val COLUMN_INDEX: Int = 0 const val ROW_INDEX: Int = 1 diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt index be43464..132bd30 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt @@ -17,9 +17,10 @@ import com.termux.terminal.TerminalEmulator * * **Glyph rendering and the cursor stay 100% stock. Scrolling and text selection do not** — both stock * implementations dereference the absent `TerminalSession`, so [RemoteTerminalHostView] owns vertical - * drags and the long press is consumed ([WebTermTerminalViewClient.onLongPress], a recorded §6.5 - * deviation). [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member: which are - * reachable, which are proven safe, and which had to be intercepted. + * drags, and the long press is consumed ([WebTermTerminalViewClient.onLongPress]) and re-spent on a + * first-party selection layer ([TerminalSelectionController]) rather than on the stock `ActionMode`, whose + * Copy button is an NPE. [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member: + * which are reachable, which are proven safe, and which had to be intercepted. * * ### What this class owns * It is the single place that knows which [RemoteTerminalSession] is bound, so it implements the @@ -33,9 +34,12 @@ import com.termux.terminal.TerminalEmulator * - **resize** — [RemoteTerminalHostView.onSizeChanged] → [TerminalResizeDriver] → * [RemoteTerminalSession.updateSize], which is the ONLY reason the server PTY ever learns the real * grid: `TerminalView.updateSize()` early-returns without a session (offsets 18–25). + * - **selection** — [WebTermTerminalViewClient.onLongPress] → [RemoteTerminalHostView.beginSelectionAt], + * then [copySelection] → `ClipboardManager`. Paste is untouched: it arrives through the IME. * - * DEVICE-QA (plan §7): rendering, IME/CJK composition, selection, link taps, focus behaviour and grid - * parity against web/iOS on the same physical size are verified on a device (risk R5). + * DEVICE-QA (plan §7): rendering, IME/CJK composition, link taps, focus behaviour and grid parity against + * web/iOS on the same physical size are verified on a device (risk R5) — and for selection specifically, + * the floating Copy toolbar, the drag feel and the highlight's alignment with the glyphs. */ public class RemoteTerminalView(context: Context) { @@ -55,7 +59,11 @@ public class RemoteTerminalView(context: Context) { } override fun onTapped() { - terminalView.showSoftKeyboard() + terminalView.onTapped() + } + + override fun onLongPressAt(xPx: Float, yPx: Float) { + terminalView.beginSelectionAt(xPx, yPx) } override fun onViewSize(widthPx: Int, heightPx: Int) { @@ -107,11 +115,38 @@ public class RemoteTerminalView(context: Context) { /** Layout-change outlet (A17/A21 install point) — the §6.4 device-switch reclaim hook. */ public var onViewSizeChanged: ((widthPx: Int, heightPx: Int) -> Unit)? = null + /** + * Fires whenever a text selection appears or disappears, so `:app` can mirror it (e.g. enable a Copy + * button in the terminal toolbar). Purely optional: the module already shows its own floating Copy + * affordance, so a host that ignores this still has working copy-out. + */ + public var onSelectionChanged: ((isSelecting: Boolean) -> Unit)? = null + init { terminalView.sink = sink // Installed BEFORE any emulator binds: touch, focus and the IME can all arrive first, and every // one of those stock paths dereferences `mClient` without a null check. terminalView.installClient(WebTermTerminalViewClient(sink)) + terminalView.onSelectionChanged = { isSelecting -> onSelectionChanged?.invoke(isSelecting) } + } + + // ── Copy-out (plan §6.5). The gesture path is self-contained; these are for `:app`'s own UI ──────── + + /** True while text is highlighted. */ + public val isSelectingText: Boolean get() = terminalView.isSelectingText + + /** + * Copy the highlighted text to the device clipboard. This is the ONLY path from terminal content to the + * clipboard (plan §8): selecting copies nothing, and OSC 52 host-clipboard writes stay declined. + * + * Safe to call with nothing selected — it answers [TerminalCopyResult.NOTHING_SELECTED] rather than + * clearing whatever the user already had. + */ + public fun copySelection(): TerminalCopyResult = terminalView.copySelection() + + /** Drop the selection (also what a tap on the terminal does). */ + public fun clearSelection() { + terminalView.clearSelection() } /** Point the stock renderer at the forked emulator, then re-assert the measured grid on it. */ diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalClipboard.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalClipboard.kt new file mode 100644 index 0000000..576a1e8 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalClipboard.kt @@ -0,0 +1,76 @@ +package wang.yaojia.webterm.terminalview + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.PersistableBundle +import android.util.Log + +/** + * The device clipboard, behind a one-method seam so the copy DECISION is unit-testable without a framework + * clipboard and so there is exactly one place in the module that can write terminal text out of the app. + */ +internal interface TerminalClipboard { + + /** @return true when [text] is now on the clipboard; false when the platform refused. */ + fun put(text: String): Boolean +} + +/** + * [TerminalClipboard] over `android.content.ClipboardManager`. + * + * ### Security posture (plan §8, §6.5) + * Terminal output is untrusted and may be a secret — an API key echoed by a script, a token in a log line. + * Two rules follow and both are enforced here rather than trusted to callers: + * + * - **Only an explicit user copy reaches this class.** There is no automatic path: OSC 52 host-clipboard + * writes stay declined in [RemoteTerminalSession] (`onCopyTextToClipboard` is a no-op), and + * [TerminalSelectionController] refuses to write without a live selection. Selecting text writes nothing. + * - **The copied text is never logged.** Failures log the throwable TYPE only, matching + * [RemoteTerminalSession]'s precedent; the payload never reaches logcat. + * + * The clip is also marked `EXTRA_IS_SENSITIVE` on API 33+, which suppresses the system clipboard preview + * overlay. The user asked for the text, but nobody asked for it to be rendered over their screen for a + * bystander — and this class cannot tell a filename from a password, so it treats every terminal copy as + * sensitive. + */ +internal class SystemTerminalClipboard(private val context: Context) : TerminalClipboard { + + override fun put(text: String): Boolean { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (clipboard == null) { + Log.w(LOG_TAG, "no clipboard service available; nothing copied") + return false + } + val clip = ClipData.newPlainText(CLIP_LABEL, text).apply { markSensitive() } + return try { + clipboard.setPrimaryClip(clip) + true + } catch (e: RuntimeException) { + // OEM clipboard services throw here (SecurityException when not the focused app, + // IllegalStateException / TransactionTooLargeException on a very large clip). A refused copy is + // a reportable outcome, never a crash. Type only — the clip content must not reach logcat. + Log.w(LOG_TAG, "clipboard refused the copy: ${e.javaClass.simpleName}") + false + } + } + + private fun ClipData.markSensitive() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + + private companion object { + /** + * Shown by the system clipboard UI. Deliberately generic: it must not describe WHICH session or + * host the text came from. + */ + const val CLIP_LABEL: String = "Terminal selection" + + const val LOG_TAG: String = "TerminalSelection" + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt index 0f0598b..f4ce42d 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalInputSink.kt @@ -26,9 +26,23 @@ internal interface TerminalInputSink { /** Raw bytes onto the wire, verbatim (invariant #9 — never filtered). */ fun sendInput(data: String) - /** The user tapped the terminal: take focus and raise the soft keyboard. */ + /** + * The user tapped the terminal. Normally: take focus and raise the soft keyboard — but with a live + * text selection the tap dismisses it instead (the standard platform gesture), which is why this is a + * "tapped" event for the view to interpret rather than a "show the keyboard" command. + */ fun onTapped() + /** + * The user long-pressed at these view pixels: start a text selection there + * ([RemoteTerminalHostView.beginSelectionAt]). + * + * The long press stays CONSUMED by [WebTermTerminalViewClient] either way — this is a notification, not + * a decision. Stock text selection must remain unreachable (its Copy button dereferences the absent + * `TerminalSession`), so the press is spent on the first-party layer instead of being handed back. + */ + fun onLongPressAt(xPx: Float, yPx: Float) + /** The host view was laid out at this pixel size (drives the §6.4 resize path). */ fun onViewSize(widthPx: Int, heightPx: Int) } diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionActionMode.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionActionMode.kt new file mode 100644 index 0000000..336a0a1 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionActionMode.kt @@ -0,0 +1,76 @@ +package wang.yaojia.webterm.terminalview + +import android.graphics.Rect +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import android.view.View +import kotlin.math.roundToInt + +/** + * The explicit Copy affordance: a floating `ActionMode` over the selection. + * + * This is plan §6.5's "Android `ActionMode` → `ClipboardManager`" with the one part that could not be kept — + * `TextSelectionCursorController` — replaced. It is our own `ActionMode.Callback2` on our own frame, so the + * menu it builds has one item that calls [onCopy], and the stock callback whose Copy is + * `mTermSession.onCopyTextToClipboard(...)` is never constructed. `TYPE_FLOATING` + [onGetContentRect] is the + * same shape stock uses (`startActionMode(callback, 1)`), which is what makes the toolbar hover over the + * selected text rather than take over the app bar. + * + * Paste is deliberately absent: it already works through the IME + * ([RemoteTerminalInputConnection] → `writeInput`), and a paste item here would only duplicate it. + * + * @param contentRect the selection's bounding box in view pixels, or null when nothing is highlighted. + * @param onCopy fired for the Copy item. The caller both copies and dismisses. + * @param onDismissed fired when the mode ends for ANY reason (Copy, back, tapping away). + */ +internal class TerminalSelectionActionMode( + private val contentRect: () -> TerminalSelectionRect?, + private val onCopy: () -> Unit, + private val onDismissed: () -> Unit, +) : ActionMode.Callback2() { + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + // android.R.string.copy is the platform's own localised "Copy", so :terminal-view needs no + // resources of its own for this. + menu.add(Menu.NONE, COPY_ITEM_ID, Menu.NONE, android.R.string.copy) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + return true + } + + /** Nothing to re-prepare: the single item is always enabled while a selection exists. */ + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + if (item.itemId != COPY_ITEM_ID) return false + onCopy() + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + onDismissed() + } + + /** + * Where the floating toolbar should point. Falls back to the platform default (the whole view) when the + * selection has scrolled out of sight, which is better than anchoring the toolbar off-screen. + */ + override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) { + val rect = contentRect() + if (rect == null) { + super.onGetContentRect(mode, view, outRect) + return + } + outRect.set( + rect.left.roundToInt(), + rect.top.roundToInt(), + rect.right.roundToInt(), + rect.bottom.roundToInt(), + ) + } + + private companion object { + /** Any non-zero id; there is only ever one item. */ + const val COPY_ITEM_ID: Int = 1 + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBuffer.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBuffer.kt new file mode 100644 index 0000000..82e7c53 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBuffer.kt @@ -0,0 +1,145 @@ +package wang.yaojia.webterm.terminalview + +import android.util.Log +import com.termux.terminal.TerminalEmulator + +/** + * Read-only access to whatever the emulator currently holds — the ONE seam the selection layer reads the + * terminal through. + * + * Two operations, both pure reads: what grid exists right now, and what text a span covers. Keeping it to + * an interface is what lets the state machine ([TerminalSelectionController]) be driven by a fake, while + * the real implementation is pinned against a real `TerminalEmulator` in the same JVM suite. + */ +internal interface TerminalSelectionBuffer { + + /** The live grid, or null before an emulator is bound. */ + fun bounds(): TerminalGridBounds? + + /** + * The text inside [span]. Never throws: an off-grid span is clamped and a missing emulator yields "". + * Implementations MUST NOT mutate anything (see [TerminalEmulatorSelectionBuffer] for why). + */ + fun textIn(span: TerminalSelectionSpan): String +} + +/** + * [TerminalSelectionBuffer] over the bound Termux emulator. + * + * ### Threading — how a read is kept off a concurrent mutation (§6.2) + * Every method here runs on the **main (render) thread**: the callers are touch/long-press/ActionMode + * callbacks and `dispatchDraw`. Since the 2026-07-30 repair, EVERY emulator mutation also runs on the main + * thread — [RemoteTerminalSession]'s single confined consumer hops to `mainDispatcher` for the `append` and + * the `resize` and does nothing to the emulator anywhere else. So a read and a mutation are two main-thread + * work items and **cannot interleave at all**. That is the same guarantee upstream Termux relies on + * (`TerminalSession$MainThreadHandler.handleMessage` calls `append`), and it is the only one available: + * `monitorenter` appears nowhere in `TerminalEmulator`, `TerminalBuffer`, `TerminalRow` or + * `TerminalRenderer`, so there is no lock to take. + * + * It is worth being explicit about why "read it anyway and let a torn read self-correct" is not on the + * table: that exact assumption was in this module's own KDoc until 2026-07-30 and it was wrong — a draw + * landing inside `TerminalEmulator.resize`'s window (new `mColumns` published at offsets 97-106, rows + * reallocated at offset 160) threw `ArrayIndexOutOfBoundsException` out of `TerminalRow.getStyle` and + * killed the process on a real emulator. `getSelectedText` indexes the same `mText`/`mLines` arrays through + * the same unsynchronised path, so a torn read here would be the same crash with the same lack of a + * self-correcting next frame. **Do not move emulator mutation off the main thread**, and do not call these + * methods from anywhere but the main thread. + * + * ### Why extraction is delegated rather than reimplemented + * `TerminalBuffer.getSelectedText(x1, y1, x2, y2)` is the one part of the stock selection stack that never + * touches `TerminalSession` — it walks rows via `TerminalRow.findStartOfColumn`, which is `WcWidth`-aware + * (so a wide CJK cell copies whole from either of its two columns), trims each line's trailing blanks, and + * joins soft-wrapped rows while preserving real line breaks. Re-deriving that in Kotlin would duplicate + * subtle logic that must agree with what the renderer draws. `TerminalSelectionBufferTest` pins the + * behaviour so a Termux bump that changes it fails loudly instead of silently copying the wrong bytes. + */ +internal class TerminalEmulatorSelectionBuffer( + private val emulator: () -> TerminalEmulator?, +) : TerminalSelectionBuffer { + + override fun bounds(): TerminalGridBounds? { + val emulator = emulator() ?: return null + return TerminalGridBounds( + columns = emulator.mColumns, + screenRows = emulator.mRows, + transcriptRows = emulator.screen.activeTranscriptRows, + ) + } + + override fun textIn(span: TerminalSelectionSpan): String { + val emulator = emulator() ?: return "" + val bounds = bounds() ?: return "" + // Clamping INSIDE the port is what makes the two unguarded stock failures unreachable for every + // caller: TerminalBuffer.externalToInternalRow throws IllegalArgumentException outside + // -activeTranscriptRows..screenRows, and TerminalRow.findStartOfColumn walks mText with no bound + // check for any column > mColumns. See TerminalSelectionSpan.clampedTo. + val clamped = span.clampedTo(bounds) ?: return "" + return try { + emulator.getSelectedText( + clamped.start.column, + clamped.start.row, + clamped.end.column, + clamped.end.row, + ) + } catch (e: IllegalArgumentException) { + // Should be unreachable after the clamp; a throw here would otherwise kill the process on the + // UI thread. Log the throwable TYPE only — never the message, which can carry row content. + logDroppedRead(e) + "" + } catch (e: IndexOutOfBoundsException) { + logDroppedRead(e) + "" + } + } + + private fun logDroppedRead(throwable: Throwable) { + Log.w(LOG_TAG, "dropping a selection read that threw: ${throwable.javaClass.simpleName}") + } + + private companion object { + private const val LOG_TAG: String = "TerminalSelection" + } +} + +/** + * The long-press snap: the run of non-blank cells around the pressed cell, so one press-and-release already + * selects something worth copying (a path, a hash, a container id) instead of a single character. + * + * ### Deliberately not stock's expansion + * `TextSelectionCursorController.setInitialTextSelectionPosition` (offsets 34-189) walks outward while the + * neighbouring cell's text `!= " "` — but a blank cell's `getSelectedText` is `""`, not `" "`, because + * trailing blanks are trimmed. So the stock comparison never matches and the expansion runs to both row + * edges: in Termux a long press selects the whole line. Testing for *blank* instead gives word-granular + * selection, which is what a shell line is actually made of; the user can still drag out to the rest of + * the line. + */ +internal object TerminalSelectionWords { + + /** + * The selection a long press at [cell] should start with, or null when there is no grid yet. + * + * The anchor is the START of the run and the focus its END, so a subsequent drag backwards past the + * word start flips the span the way a text editor does. + */ + fun runAt(cell: TerminalCell, buffer: TerminalSelectionBuffer): TerminalSelection? { + val bounds = buffer.bounds() ?: return null + if (!bounds.isMeasured) return null + val pressed = bounds.clamp(cell) + if (isBlank(pressed, buffer)) return TerminalSelection.at(pressed) + + var first = pressed.column + while (first > 0 && !isBlank(pressed.copy(column = first - 1), buffer)) first-- + + var last = pressed.column + while (last < bounds.lastColumn && !isBlank(pressed.copy(column = last + 1), buffer)) last++ + + return TerminalSelection.anchoredOn( + from = pressed.copy(column = first), + to = pressed.copy(column = last), + ) + } + + /** A cell is blank when its extracted text is empty (trailing-blank trimming) or whitespace. */ + private fun isBlank(cell: TerminalCell, buffer: TerminalSelectionBuffer): Boolean = + buffer.textIn(TerminalSelectionSpan(cell, cell)).isBlank() +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionController.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionController.kt new file mode 100644 index 0000000..b668f0e --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionController.kt @@ -0,0 +1,124 @@ +package wang.yaojia.webterm.terminalview + +/** What an explicit copy did. Public so `:app` can report the outcome in its own UI. */ +public enum class TerminalCopyResult { + /** The selected text is on the clipboard. */ + COPIED, + + /** Nothing was selected, so there was nothing to copy. */ + NOTHING_SELECTED, + + /** The selection resolved to blank cells; the clipboard was deliberately left alone. */ + NOTHING_TO_COPY, + + /** The platform clipboard refused the write (see [SystemTerminalClipboard]). */ + CLIPBOARD_UNAVAILABLE, +} + +/** + * The selection state machine: what a long press starts, what a drag does to it, what new output does to + * it, and the single path from a live selection to the clipboard. + * + * Pure of android and of Termux — the terminal arrives through [TerminalSelectionBuffer] and leaves through + * [TerminalClipboard], so every decision here is JVM-unit-testable. The view layer + * ([RemoteTerminalHostView]) owns only pixels→cells and the affordance. + * + * Not thread-safe by design: like [TerminalResizeDriver] it is driven from touch and draw callbacks, i.e. + * the main thread only. That is also the thread the emulator is mutated on, which is what makes the buffer + * reads safe (see [TerminalEmulatorSelectionBuffer]). + * + * @param onChanged fired with the new "is a selection active" value whenever it or the span changes. The + * view uses it to repaint the highlight and show/hide the Copy affordance. + */ +internal class TerminalSelectionController( + private val buffer: TerminalSelectionBuffer, + private val clipboard: TerminalClipboard, + private val onChanged: (isActive: Boolean) -> Unit, +) { + /** The live selection, or null when there is none. Immutable — every change replaces it. */ + var selection: TerminalSelection? = null + private set + + val isActive: Boolean get() = selection != null + + /** + * Start a selection at [cell], snapped to the word under it ([TerminalSelectionWords]). + * + * @return true when a selection now exists. False means the grid is not measured yet (nothing is bound, + * or the view has not been laid out), in which case the long press is simply ignored. + */ + fun beginAt(cell: TerminalCell): Boolean { + val started = TerminalSelectionWords.runAt(cell, buffer) ?: return false + publish(started) + return true + } + + /** Move the focus end to [cell] (clamped into the live grid). No-op without a selection. */ + fun extendTo(cell: TerminalCell) { + val current = selection ?: return + val bounds = buffer.bounds() ?: return + if (!bounds.isMeasured) return + publish(current.withFocus(bounds.clamp(cell))) + } + + /** + * Drop the selection. + * + * Reports nothing when there was no selection — load-bearing, because the Copy affordance is dismissed + * BY this method and its own dismissal callback calls it back; without the guard that is an infinite + * loop (see [RemoteTerminalHostView]'s action-mode handling). + */ + fun clear() { + if (selection == null) return + selection = null + onChanged(false) + } + + /** The text the current selection covers, or "" when there is none. Reads the buffer; mutates nothing. */ + fun selectedText(): String { + val span = selection?.span ?: return "" + return buffer.textIn(span) + } + + /** + * THE explicit copy — the only path from terminal content to the device clipboard (plan §8). + * + * A blank result is refused rather than written: replacing whatever the user had on their clipboard with + * an empty string is data loss, and it is the likely outcome of a stray long press on empty space. + */ + fun copy(): TerminalCopyResult { + if (selection == null) return TerminalCopyResult.NOTHING_SELECTED + val text = selectedText() + if (text.isBlank()) return TerminalCopyResult.NOTHING_TO_COPY + return if (clipboard.put(text)) TerminalCopyResult.COPIED else TerminalCopyResult.CLIPBOARD_UNAVAILABLE + } + + /** + * [scrolledRows] new rows scrolled off the top of the screen, so every external row index the selection + * holds is now that much smaller. + * + * Called from the screen-update path with `TerminalEmulator.getScrollCounter()` read BEFORE the stock + * `onScreenUpdated()` clears it — the stock view only maintains this for its own selection controller + * (`isSelectingText()`, always false here), so the shift has to be applied by us or the highlight ends + * up pointing at different text than the user selected. Once the top of the selection falls past the + * oldest retained transcript row the text is genuinely gone, and the selection is dropped rather than + * silently re-pointed — mirroring stock's `stopTextSelectionMode()` in the same situation. + */ + fun onContentScrolled(scrolledRows: Int) { + if (scrolledRows == 0) return + val current = selection ?: return + val bounds = buffer.bounds() ?: return + val moved = current.movedBy(-scrolledRows) + if (moved.span.start.row < bounds.firstRow) { + clear() + return + } + publish(moved) + } + + private fun publish(next: TerminalSelection) { + if (next == selection) return + selection = next + onChanged(true) + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometry.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometry.kt new file mode 100644 index 0000000..9b12011 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometry.kt @@ -0,0 +1,80 @@ +package wang.yaojia.webterm.terminalview + +import kotlin.math.floor + +/** One highlight band, in the host frame's own pixel space (which is the stock child's — it sits at 0,0). */ +internal data class TerminalSelectionRect( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, +) + +/** + * Pixels ↔ cells for the selection layer. Pure: the caller supplies the measured cell box and the + * viewport's `topRow`, so both directions are JVM-unit-testable without a device — the same trick + * [TerminalGridMath] plays for the resize formula. + * + * ### The mapping, and why it is not stock's + * This is the exact inverse of the stock accessors this module already reads: + * `TerminalView.getPointX(col)` is `round(col * fontWidth)` and `getPointY(row)` is + * `(row - topRow) * lineSpacing`, i.e. cell `(c, r)` owns the band + * `[c*fontWidth, (c+1)*fontWidth) x [(r-topRow)*lineSpacing, (r-topRow+1)*lineSpacing)`. `TerminalRenderer` + * draws each row's baseline inside that same band (`y += mFontLineSpacing` per row, starting at + * `mFontLineSpacingAndAscent`), so a highlight built this way lines up with the glyphs. + * + * Stock's own hit test ([com.termux.view.TerminalView.getColumnAndRow]) instead computes + * `(y - mFontLineSpacingAndAscent) / mFontLineSpacing`, which subtracts the (negative) font ascent and so + * lands roughly a fifth of a row above the glyph the user pressed. Reproducing that would make a long + * press select the line above itself near a row boundary, so it is deliberately not reproduced. + */ +internal object TerminalSelectionGeometry { + + /** + * The cell under a touch, or null when the renderer has not measured a cell box yet (before the first + * layout `fontWidth`/`lineSpacing` are 0 and there is no grid to divide by). + * + * The result is NOT clamped — a touch outside the grid legitimately resolves to an off-grid cell, and + * clamping happens once, against the live bounds, in [TerminalSelectionSpan.clampedTo]. + */ + fun cellAt(xPx: Float, yPx: Float, metrics: TerminalCellMetrics, topRow: Int): TerminalCell? { + if (metrics.fontWidthPx <= 0f || metrics.lineSpacingPx <= 0) return null + return TerminalCell( + column = floor(xPx / metrics.fontWidthPx).toInt(), + row = floor(yPx / metrics.lineSpacingPx).toInt() + topRow, + ) + } + + /** + * One band per VISIBLE row of [span]. + * + * Rows above or below the viewport are dropped rather than emitted with an off-screen `top`: a + * selection made in scrollback and then scrolled away must paint nothing, and a 10 000-row transcript + * selection must not build 10 000 rects for a 24-row screen. + */ + fun highlightRects( + span: TerminalSelectionSpan, + bounds: TerminalGridBounds, + metrics: TerminalCellMetrics, + topRow: Int, + ): List { + if (!bounds.isMeasured) return emptyList() + if (metrics.fontWidthPx <= 0f || metrics.lineSpacingPx <= 0) return emptyList() + + val firstVisibleRow = maxOf(span.start.row, topRow) + val lastVisibleRow = minOf(span.end.row, topRow + bounds.screenRows - 1) + if (firstVisibleRow > lastVisibleRow) return emptyList() + + return (firstVisibleRow..lastVisibleRow).mapNotNull { row -> + val columns = span.columnsIn(row, bounds.lastColumn) ?: return@mapNotNull null + val top = (row - topRow) * metrics.lineSpacingPx.toFloat() + TerminalSelectionRect( + left = columns.first * metrics.fontWidthPx, + top = top, + // Inclusive columns → the band covers the whole last cell, hence the +1. + right = (columns.last + 1) * metrics.fontWidthPx, + bottom = top + metrics.lineSpacingPx, + ) + } + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModel.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModel.kt new file mode 100644 index 0000000..0efb929 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModel.kt @@ -0,0 +1,169 @@ +package wang.yaojia.webterm.terminalview + +/** + * The first-party text-selection model — cell coordinates, normalisation and clamping. + * + * ### Why this exists at all (the stock selection UI is a crash, not a feature) + * `TerminalView.startTextSelectionMode` arms an `ActionMode` whose own handlers dereference the + * `TerminalSession` this app deliberately does not have: + * `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 are + * `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 are `mTermSession.onPasteTextFromClipboard()` + * (re-verified with `javap -p -c` on the pinned `terminal-view-v0.118.0.aar`). So making the stock UI + * reachable buys a selection whose **Copy button is an NPE**, which is strictly worse than no selection — + * the user loses their work to a process kill instead of finding the affordance absent. `mTermSession` + * cannot be supplied: `TerminalSession` is `final` and its constructor forks a local process, which is + * exactly what this app must not do (plan §6.1). + * + * The one thing in that stock stack that IS session-free is the extraction — + * `TerminalBuffer.getSelectedText(x1, y1, x2, y2)` reads rows and nothing else. So the replacement is: our + * own coordinates (this file), our own hit-testing and highlight ([TerminalSelectionGeometry]), stock + * extraction ([TerminalSelectionBuffer]) and a direct `ClipboardManager` write ([TerminalClipboard]) — + * `TextSelectionCursorController` bypassed entirely, `onLongPress` still consuming so the stock mode can + * never arm. + * + * Everything in this file is pure and immutable: no android, no Termux, no mutation. Every operation + * returns a new value. + */ + +/** + * One terminal cell, ordered **row-major** — which is what makes a drag up-and-right still a backwards + * drag, and lets [TerminalSelectionSpan] normalise with plain `minOf`/`maxOf`. + * + * @param column 0-based, left to right. + * @param row an **external** row in Termux's sense: `0 .. screenRows-1` is the live screen and negative + * rows walk back into the scrollback transcript. External rows are independent of the viewport's + * `topRow`, so scrolling the view never moves a selection — but new output does (see [movedBy]). + */ +internal data class TerminalCell(val column: Int, val row: Int) : Comparable { + + override fun compareTo(other: TerminalCell): Int = + if (row != other.row) row.compareTo(other.row) else column.compareTo(other.column) + + fun movedBy(rows: Int): TerminalCell = copy(row = row + rows) +} + +/** + * An **inclusive**, row-major-ordered cell range: [start] is never after [end]. + * + * That invariant is established by construction — every producer ([spanning], [clampedTo], [expandedTo]) + * orders its output — and it is what lets [columnsIn] and the buffer read treat start/end as first/last + * without re-checking. + */ +internal data class TerminalSelectionSpan(val start: TerminalCell, val end: TerminalCell) { + + val isSingleCell: Boolean get() = start == end + + /** The rows this span touches, oldest (smallest) first. */ + val rows: IntRange get() = start.row..end.row + + /** + * The columns selected on [row], or null when [row] is outside the span. + * + * The first row runs from its start column to the grid edge, the last row from column 0 to its end + * column, and every row between is whole — matching `TerminalBuffer.getSelectedText`'s own per-row + * range (offsets 57-98), so the highlight and the copied text can never disagree. + */ + fun columnsIn(row: Int, lastColumn: Int): IntRange? { + if (row < start.row || row > end.row) return null + val first = if (row == start.row) start.column else 0 + val last = if (row == end.row) end.column else lastColumn + return first..last + } + + /** This span grown to include [cell]. A cell already inside it changes nothing. */ + fun expandedTo(cell: TerminalCell): TerminalSelectionSpan = + TerminalSelectionSpan(minOf(start, cell), maxOf(end, cell)) + + fun movedBy(rows: Int): TerminalSelectionSpan = + TerminalSelectionSpan(start.movedBy(rows), end.movedBy(rows)) + + /** + * This span clamped into [bounds], or null when the grid has not been measured yet. + * + * **Load-bearing, not defensive dressing.** An unclamped cell reaches two unguarded stock paths: + * `TerminalBuffer.externalToInternalRow` THROWS `IllegalArgumentException` for a row outside + * `-activeTranscriptRows .. screenRows`, and `TerminalRow.findStartOfColumn` walks `mText` with **no + * bound check** for any `column > mColumns` (offsets 13-173 — only the exact `column == mColumns` case + * short-circuits), so an off-grid column runs off the end of the char array. A touch a few pixels past + * the last cell, or a drag that leaves the view, produces exactly those inputs. + */ + fun clampedTo(bounds: TerminalGridBounds): TerminalSelectionSpan? { + if (!bounds.isMeasured) return null + return TerminalSelectionSpan(bounds.clamp(start), bounds.clamp(end)) + } + + companion object { + /** The normalising factory: the two cells in either order produce the same span. */ + fun spanning(one: TerminalCell, other: TerminalCell): TerminalSelectionSpan = + TerminalSelectionSpan(minOf(one, other), maxOf(one, other)) + } +} + +/** + * A live selection as the user's gesture describes it. + * + * @param anchor what the long press claimed — a **span**, not a cell, because a press snaps to the word + * under it ([TerminalSelectionWords]). Keeping the whole anchor means a drag backwards past the word + * start still leaves that word selected, the way a text editor behaves, instead of collapsing it to the + * single cell the drag passed through. + * @param focus where the finger is now. + */ +internal data class TerminalSelection(val anchor: TerminalSelectionSpan, val focus: TerminalCell) { + + /** + * The normalised, inclusive span: the anchor grown to reach the focus. A backwards drag yields exactly + * the same span as the equivalent forwards one — [TerminalSelectionSpan.expandedTo] orders by + * construction, so there is no direction to get wrong. + */ + val span: TerminalSelectionSpan get() = anchor.expandedTo(focus) + + fun withFocus(cell: TerminalCell): TerminalSelection = copy(focus = cell) + + /** + * Shift the whole selection by [rows] (negative == the screen scrolled up under a live selection). + * + * New output renumbers external rows: the line the user selected is one row lower after each new line + * arrives. Without this shift the highlight would keep pointing at the same coordinates while the text + * under it changed — and the Copy button would then put *different* text on the clipboard than the one + * highlighted, which for terminal content (possibly secrets) is a correctness bug, not a cosmetic one. + */ + fun movedBy(rows: Int): TerminalSelection = + TerminalSelection(anchor.movedBy(rows), focus.movedBy(rows)) + + companion object { + /** A selection of one cell — the fallback when a press lands on blank space. */ + fun at(cell: TerminalCell): TerminalSelection = + TerminalSelection(TerminalSelectionSpan(cell, cell), cell) + + /** A selection anchored on a whole run of cells, focused at its end. */ + fun anchoredOn(from: TerminalCell, to: TerminalCell): TerminalSelection = + TerminalSelection(TerminalSelectionSpan.spanning(from, to), maxOf(from, to)) + } +} + +/** + * The live grid a selection is resolved against: the emulator's `mColumns` / `mRows` and its buffer's + * `activeTranscriptRows`. Read fresh for every operation (the grid changes on resize and the transcript + * grows with output), never cached. + */ +internal data class TerminalGridBounds( + val columns: Int, + val screenRows: Int, + val transcriptRows: Int, +) { + /** False before the first layout/bind, when there is no grid to clamp into. */ + val isMeasured: Boolean get() = columns > 0 && screenRows > 0 + + val lastColumn: Int get() = columns - 1 + + /** The oldest retained row; `getSelectedText` clamps to the same floor (offsets 15-29). */ + val firstRow: Int get() = -transcriptRows + + /** The newest row of the live screen; `getSelectedText` clamps to the same ceiling (offsets 30-46). */ + val lastRow: Int get() = screenRows - 1 + + fun clamp(cell: TerminalCell): TerminalCell = TerminalCell( + column = cell.column.coerceIn(0, lastColumn), + row = cell.row.coerceIn(firstRow, lastRow), + ) +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionPainter.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionPainter.kt new file mode 100644 index 0000000..781e1a9 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionPainter.kt @@ -0,0 +1,44 @@ +package wang.yaojia.webterm.terminalview + +import android.graphics.Canvas +import android.graphics.Paint + +/** + * Paints the selection highlight as an OVERLAY, on top of the stock glyphs. + * + * ### Why an overlay rather than the renderer's own selection channel + * `TerminalRenderer.render` already knows how to invert selected cells — `TerminalView.onDraw` passes it + * four selector ints and the renderer flags every column between them. That channel is + * `TerminalView.mDefaultSelectors`, which is **package-private** to `com.termux.view`, so reaching it would + * take either reflection (silently broken by a version bump, and the module already refused reflection for + * the cell metrics) or a source file planted in Termux's package. Neither is worth it for a highlight, and + * plan §6.1 keeps rendering stock: nothing here patches `TerminalRenderer` or `TerminalView.onDraw`. + * + * A translucent fill drawn after the children therefore does the job. It is drawn in the host frame's own + * coordinate space, which is the stock child's — the child fills the frame at (0,0) and `onDraw` applies no + * canvas translation, so the bands line up with the glyphs cell for cell. + */ +internal class TerminalSelectionPainter { + + private val fill = Paint().apply { + color = SELECTION_ARGB + style = Paint.Style.FILL + // Cell-aligned rectangles: smoothing their edges only blurs the boundary between two selected rows. + isAntiAlias = false + } + + fun draw(canvas: Canvas, rects: List) { + rects.forEach { rect -> canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, fill) } + } + + private companion object { + /** + * The design system's amber-gold (#E3A64A) at 35% alpha. + * + * Translucent because it is drawn OVER the glyphs: an opaque fill would hide the very text the user + * is about to copy. The literal is inline because `:terminal-view` has no dependency on `:app`'s + * design tokens (dependencies only flow down) — keep it in step with `WebTermColors` by hand. + */ + const val SELECTION_ARGB: Int = 0x59E3A64A.toInt() + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt index 0c77f7c..e0239fa 100644 --- a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClient.kt @@ -36,7 +36,10 @@ import com.termux.view.TerminalViewClient * reachable and which are proven safe. Rendering and the cursor stay stock; scrolling and text selection * do not (plan §6.1/§6.5 deviations, both recorded there and on [onLongPress]). * - + * Text selection is the one that changed shape rather than being cut: the long press is still consumed — + * the stock `ActionMode` can never arm — but it now starts the first-party selection layer + * ([TerminalSelectionController]) instead of being discarded. + * * @param sink the single outlet — `:app`'s chord router, the emulator's live cursor modes, and the byte * send pump. See [TerminalInputSink]. */ @@ -126,44 +129,31 @@ internal class WebTermTerminalViewClient(private val sink: TerminalInputSink) : override fun onScale(scale: Float): Float = NO_SCALE /** - * MUST stay true — text selection is **cut**, a recorded deviation from plan §6.5. + * Start a FIRST-PARTY text selection at the press, and consume the event. * - * `TerminalView$1.onLongPress` offsets 14-30 are `if (mClient.onLongPress(e)) return;`, so true is the - * one supported way to stop `startTextSelectionMode` from arming. It has to be stopped, because the - * stock selection ActionMode is a crash rather than a feature without a `TerminalSession`: + * **The return value MUST stay true.** `TerminalView$1.onLongPress` offsets 14-30 are + * `if (mClient.onLongPress(e)) return;`, so true is the one supported way to stop + * `startTextSelectionMode` from arming — and it has to be stopped, because the stock selection + * `ActionMode` is a crash rather than a feature without a `TerminalSession`: * `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 (**Copy**) are * `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 (**Paste**) are * `mTermSession.onPasteTextFromClipboard()` — both unguarded, both on the only two buttons the mode * exists for. Selecting text would work; the moment the user tapped Copy the process would die. + * `mTermSession` cannot be supplied either: `TerminalSession` is `final`, its constructor builds a + * process-bound `MainThreadHandler`, and having no local subprocess is the point (plan §6.1). * - * The controller itself is otherwise session-free (it reads `TerminalBuffer.getSelectedText`), so a - * clipboard path is buildable — but it needs its own ActionMode, its own copy action and its own - * selection highlight, i.e. a feature, not a guard. Until then a long press is a no-op instead of a - * loaded gun, and the deviation is reported to the plan owner rather than left silently dead. + * Copy-out is therefore **no longer a feature gap**: the press is spent on the replacement layer + * instead of being discarded. [TerminalSelection] carries the design — our own cell coordinates + * and highlight, stock's session-free `TerminalBuffer.getSelectedText` for the extraction, and a direct + * `ClipboardManager` write ([TerminalClipboard]), with `TextSelectionCursorController` bypassed + * entirely. The affordance is our own floating `ActionMode` ([TerminalSelectionActionMode]), which is + * what plan §6.5 asks for; only the stock controller is replaced. Paste is untouched — it already works + * through the IME ([RemoteTerminalInputConnection]). */ - /** - * Consume the long press, which suppresses stock text selection. - * - * **RECORDED DEVIATION from plan §6.5** ("Selection/copy: Termux TextSelectionCursorController + - * Android ActionMode → ClipboardManager"), and a KNOWN FEATURE GAP — not an oversight, and not the - * cheaper of two equal options. An adversarial review asked for stock selection to be RESTORED after - * an earlier revision made it unreachable. Disassembling the pinned artifact shows restoring it would - * only relocate the crash rather than remove it: the ActionMode's own handlers dereference the absent - * session. `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 are - * `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 are - * `mTermSession.onPasteTextFromClipboard()`. So `startTextSelectionMode` being reachable buys a - * selection UI whose **Copy button is an NPE** — strictly worse than no selection, because the user - * loses their work to a crash instead of finding the affordance absent. - * - * `mTermSession` cannot be supplied: `TerminalSession` is `final`, its constructor builds a - * process-bound `MainThreadHandler`, and this app deliberately has no local subprocess (plan §6.1). - * - * The real fix is a first-party selection layer that reads the emulator's `TerminalBuffer` and talks - * to `ClipboardManager` directly, bypassing `TextSelectionCursorController` entirely. That is a - * feature, not a crash fix, so it is tracked in DEVICE_QA_CHECKLIST rather than smuggled in here. - * Until then: copy-out is unavailable, and paste-in still works through the IME. - */ - override fun onLongPress(event: MotionEvent?): Boolean = true + override fun onLongPress(event: MotionEvent?): Boolean { + event?.let { sink.onLongPressAt(it.x, it.y) } + return true + } override fun copyModeChanged(copyMode: Boolean) = Unit diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSelectionTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSelectionTest.kt new file mode 100644 index 0000000..9c25eef --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSelectionTest.kt @@ -0,0 +1,319 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.TerminalEmulator +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_HEIGHT_PX +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_WIDTH_PX +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.down +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.move +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.up + +/** + * Copy-out, driven through the real host frame and the real `TerminalViewClient` — the first-party + * replacement for the stock selection UI that cannot be made reachable (its Copy button dereferences the + * absent `TerminalSession`; see [TerminalSelection]'s doc and [WebTermTerminalViewClient.onLongPress]). + * + * The gesture entry point is called directly because Termux's `GestureAndScaleRecognizer` is framework code, + * stripped by the mockable `android.jar`. Everything after it — the client's decision, the pixel→cell + * mapping, the touch routing, the state machine, the buffer read — is the real compiled path. + */ +class RemoteTerminalSelectionTest { + + private lateinit var host: RemoteTerminalHostView + private lateinit var client: WebTermTerminalViewClient + private lateinit var clipboard: RecordingClipboard + private lateinit var sink: SelectionRoutingSink + + /** The same forwarding [RemoteTerminalView] installs: long press and tap belong to the frame. */ + private class SelectionRoutingSink(private val host: () -> RemoteTerminalHostView) : TerminalInputSink { + val sent = mutableListOf() + var keyboardRequests = 0 + + override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false + override fun cursorModes(): TerminalCursorModes? = null + override fun sendInput(data: String) { + sent += data + } + + override fun onTapped() { + if (host().isSelectingText) host().clearSelection() else keyboardRequests++ + } + + override fun onLongPressAt(xPx: Float, yPx: Float) { + host().beginSelectionAt(xPx, yPx) + } + + override fun onViewSize(widthPx: Int, heightPx: Int) = Unit + } + + private class RecordingClipboard : TerminalClipboard { + val puts = mutableListOf() + + override fun put(text: String): Boolean { + puts += text + return true + } + } + + @BeforeEach + fun setUp() { + StockTerminalViewFixture.mockCellMetrics() + clipboard = RecordingClipboard() + host = RemoteTerminalHostView(StockTerminalViewFixture.context(), clipboard) + sink = SelectionRoutingSink { host } + host.sink = sink + client = WebTermTerminalViewClient(sink) + host.installClient(client) + } + + @AfterEach fun tearDown() = unmockkAll() + + private fun bind(vararg output: String): TerminalEmulator { + val emulator = StockTerminalViewFixture.emulator() + output.forEach { emulator.feed(it) } + host.bindEmulator(emulator) + return emulator + } + + /** Pixel centre of a cell, using the fixture's pinned cell box. */ + private fun x(column: Int) = (column + 0.5f) * CELL_WIDTH_PX + private fun y(row: Int) = (row + 0.5f) * CELL_HEIGHT_PX + + private fun longPress(column: Int, row: Int) { + assertTrue( + client.onLongPress(down(y = y(row), x = x(column))), + "the long press must stay CONSUMED so stock text selection can never arm", + ) + } + + // ── The constraint: the stock selection mode stays unreachable ─────────────────────────────────── + + @Test + fun `a long press starts a first-party selection and never arms the stock ActionMode`() { + bind("cat /etc/hosts") + + longPress(column = 6, row = 0) + + assertTrue(host.isSelectingText, "the first-party layer owns the selection") + assertFalse( + host.stockView.isSelectingText, + "TextSelectionCursorController's Copy is mTermSession.onCopyTextToClipboard — an NPE", + ) + } + + @Test + fun `a long press before an emulator is bound is simply ignored`() { + longPress(column = 3, row = 0) + + assertFalse(host.isSelectingText) + } + + // ── Selecting, extending, copying ─────────────────────────────────────────────────────────────── + + @Test + fun `the long press snaps to the word under the finger and copies exactly that`() { + bind("cat /etc/hosts") + longPress(column = 6, row = 0) + + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + + assertEquals(listOf("/etc/hosts"), clipboard.puts) + } + + @Test + fun `dragging after the long press extends the selection instead of scrolling`() { + // The same vertical drag with no selection is a transcript scroll / arrow-key emit; with a live + // selection it must extend, or the feature is unusable on a touch screen. + bind("alpha\r\nbravo\r\ncharlie\r\n") + longPress(column = 0, row = 0) + + assertTrue(host.onInterceptTouchEvent(move(y = y(2), x = x(2))), "the extend drag is claimed") + host.onTouchEvent(up(y = y(2), x = x(2))) + + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + assertEquals(listOf("alpha\nbravo\ncha"), clipboard.puts) + } + + @Test + fun `an extend drag inside an alternate-screen TUI sends no arrow keys`() { + // Without the selection branch this drag would go to TerminalScrollGesture and type into the TUI. + bind(ENTER_ALTERNATE_BUFFER, "vim buffer") + longPress(column = 0, row = 0) + + host.onInterceptTouchEvent(move(y = y(3), x = x(5))) + host.onTouchEvent(up(y = y(3), x = x(5))) + + assertTrue(sink.sent.isEmpty(), "extending a selection must not reach the shell") + } + + @Test + fun `a drag backwards past the anchor still selects forwards`() { + bind("alpha\r\nbravo\r\ncharlie\r\n") + longPress(column = 2, row = 2) // snaps to "charlie", columns 0-6 + + host.onInterceptTouchEvent(move(y = y(0), x = x(2))) + host.onTouchEvent(up(y = y(0), x = x(2))) + + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + assertEquals(listOf("pha\nbravo\ncharlie"), clipboard.puts) + } + + @Test + fun `a second finger during an extend is not read as an extend`() { + bind("alpha\r\nbravo\r\ncharlie\r\n") + longPress(column = 0, row = 0) + val before = host.copySelection().also { clipboard.puts.clear() } + + host.onInterceptTouchEvent(move(y = y(2), x = x(2), pointers = 2)) + + assertEquals(TerminalCopyResult.COPIED, before) + assertEquals(TerminalCopyResult.COPIED, host.copySelection()) + assertEquals(listOf("alpha"), clipboard.puts, "the pinch left the selection alone") + } + + // ── Dismissal ─────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a tap dismisses the selection instead of popping the keyboard`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + client.onSingleTapUp(down(y = y(0), x = x(0))) + + assertFalse(host.isSelectingText) + assertEquals(0, sink.keyboardRequests, "the tap is spent on the dismissal") + } + + @Test + fun `a tap with no selection still raises the keyboard`() { + bind("cat /etc/hosts") + + client.onSingleTapUp(down(y = y(0), x = x(0))) + + assertEquals(1, sink.keyboardRequests) + } + + @Test + fun `after a dismissal a drag scrolls again`() { + val emulator = bind() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + longPress(column = 0, row = 0) + client.onSingleTapUp(down(y = y(0), x = x(0))) + + assertFalse(host.onInterceptTouchEvent(down(START_Y))) + assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX))) + host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + 2 * CELL_HEIGHT_PX)) + + assertEquals(-2, host.stockView.topRow) + } + + @Test + fun `binding a new emulator drops a stale selection`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + bind("a different session") + + assertFalse(host.isSelectingText, "the old span points into a transcript that no longer exists") + } + + @Test + fun `a layout change drops the selection`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + // A new size means a regrid and a reflow: the cells the span names are about to hold other text. + host.onSizeChanged(VIEW_WIDTH_PX, VIEW_HEIGHT_PX, 0, 0) + + assertFalse(host.isSelectingText) + } + + // ── Live output under a selection ─────────────────────────────────────────────────────────────── + + @Test + fun `new output shifts the selection and pins the viewport so the highlight stays on its text`() { + val emulator = bind("target\r\n") + // Overfills the screen on purpose: those rows scroll BEFORE the press, so the emulator's scroll + // counter is already non-zero when the selection is made. Counting them would shift the span by + // rows it never saw — which is why beginSelectionAt zeroes the counter. + repeat(SCREEN_ROWS) { emulator.feed("filler\r\n") } + longPress(column = 1, row = 0) + val selectedBefore = host.selectedText() + + repeat(SCROLL_ROWS) { emulator.feed("more\r\n") } + host.requestScreenUpdate() + + assertEquals(selectedBefore, host.selectedText(), "the same TEXT, at a new row index") + assertEquals( + -SCROLL_ROWS, + host.stockView.topRow, + "stock onScreenUpdated jumps to the newest row unless we pin it back", + ) + } + + @Test + fun `output with no selection still jumps to the newest row`() { + val emulator = bind() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + host.stockView.topRow = -5 + + host.requestScreenUpdate() + + assertEquals(0, host.stockView.topRow, "the pin is for selections only") + } + + @Test + fun `a selection scrolled out of the transcript is dropped`() { + val emulator = StockTerminalViewFixture.emulator(transcriptRows = SMALL_TRANSCRIPT) + host.bindEmulator(emulator) + emulator.feed("target\r\n") + longPress(column = 1, row = 0) + + repeat(SMALL_TRANSCRIPT + SCREEN_ROWS + 1) { emulator.feed("flood\r\n") } + host.requestScreenUpdate() + + assertFalse(host.isSelectingText) + } + + // ── Highlight geometry ────────────────────────────────────────────────────────────────────────── + + @Test + fun `the highlight covers the selected cells`() { + bind("cat /etc/hosts") + longPress(column = 1, row = 0) + + val rects = host.highlightRects() + + assertEquals(1, rects.size) + assertEquals(0f, rects[0].left) + assertEquals(3 * CELL_WIDTH_PX, rects[0].right, "'cat' is columns 0-2") + assertEquals(0f, rects[0].top) + assertEquals(CELL_HEIGHT_PX.toFloat(), rects[0].bottom) + } + + @Test + fun `there is no highlight without a selection`() { + bind("cat /etc/hosts") + + assertTrue(host.highlightRects().isEmpty()) + } + + private companion object { + const val SCREEN_ROWS: Int = 24 + const val SCROLLED_OFF_LINES: Int = 40 + const val SCROLL_ROWS: Int = 3 + const val SMALL_TRANSCRIPT: Int = 100 + const val START_Y: Float = 400f + const val CLAIM_TRAVEL_PX: Float = 100f + const val VIEW_WIDTH_PX: Int = 720 + const val VIEW_HEIGHT_PX: Int = 1280 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt index b3ee39c..04f0bc8 100644 --- a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/StockTerminalViewFixture.kt @@ -120,6 +120,7 @@ internal object StockTerminalViewFixture { internal class RecordingInputSink : TerminalInputSink { val sent = mutableListOf() var taps = 0 + val longPresses = mutableListOf>() val sizes = mutableListOf>() override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false @@ -132,6 +133,10 @@ internal class RecordingInputSink : TerminalInputSink { taps++ } + override fun onLongPressAt(xPx: Float, yPx: Float) { + longPresses += xPx to yPx + } + override fun onViewSize(widthPx: Int, heightPx: Int) { sizes += widthPx to heightPx } diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBufferTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBufferTest.kt new file mode 100644 index 0000000..e1c8eb1 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionBufferTest.kt @@ -0,0 +1,186 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.TerminalEmulator +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed + +/** + * Text extraction, against the **real** Termux `TerminalBuffer` driven by real bytes. + * + * This is the part of the stock selection stack that is session-free and therefore reusable: + * `TerminalBuffer.getSelectedText` reads rows and nothing else — no `TerminalSession`, no view. Re-deriving + * its wide-cell walk (`TerminalRow.findStartOfColumn` + `WcWidth`), its per-line trailing-blank trim and its + * soft-wrap joining in Kotlin would be a second implementation of the same subtle logic, guaranteed to + * drift from what the renderer draws. So the extraction is delegated and PINNED here instead: these tests + * assert the behaviour this module depends on, so a Termux bump that changes it fails loudly. + */ +class TerminalSelectionBufferTest { + + private fun bufferOf(emulator: TerminalEmulator?): TerminalSelectionBuffer = + TerminalEmulatorSelectionBuffer { emulator } + + private fun emulatorWith(vararg output: String): TerminalEmulator = + StockTerminalViewFixture.emulator().also { emulator -> output.forEach { emulator.feed(it) } } + + private fun TerminalSelectionBuffer.text(fromColumn: Int, fromRow: Int, toColumn: Int, toRow: Int) = + textIn(TerminalSelectionSpan.spanning(TerminalCell(fromColumn, fromRow), TerminalCell(toColumn, toRow))) + + // ── Bounds ─────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `bounds come from the live emulator and its transcript`() { + val emulator = emulatorWith() + repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") } + + val bounds = bufferOf(emulator).bounds() + + assertEquals(TerminalGridBounds(COLUMNS, SCREEN_ROWS, emulator.screen.activeTranscriptRows), bounds) + assertTrue(bounds!!.transcriptRows > 0, "40 lines on a 24-row screen must leave a transcript") + } + + @Test + fun `there are no bounds and no text before an emulator is bound`() { + val buffer = bufferOf(null) + + assertNull(buffer.bounds()) + assertEquals("", buffer.text(0, 0, 5, 0)) + } + + // ── Trailing blanks, wide cells, line joining ──────────────────────────────────────────────────── + + @Test + fun `a row's trailing blanks are trimmed rather than copied as spaces`() { + val buffer = bufferOf(emulatorWith("hi")) + + // Selecting the whole row must not yield "hi" followed by 78 spaces. + assertEquals("hi", buffer.text(0, 0, COLUMNS - 1, 0)) + } + + @Test + fun `a wide CJK cell is copied whole from either of its two columns`() { + // 你 occupies columns 0-1 and 好 columns 2-3; "world" then starts at column 4. + val buffer = bufferOf(emulatorWith("你好world")) + + assertEquals("你", buffer.text(0, 0, 1, 0)) + assertEquals("你", buffer.text(1, 0, 1, 0), "the right half of a wide cell is still that cell") + assertEquals("你好", buffer.text(0, 0, 3, 0)) + assertEquals("好world", buffer.text(2, 0, 8, 0)) + } + + @Test + fun `a soft-wrapped line is joined, so a long command copies as one line`() { + val buffer = bufferOf(emulatorWith("a".repeat(WRAPPED_LINE_LENGTH))) + + val copied = buffer.text(0, 0, WRAPPED_LINE_LENGTH - COLUMNS - 1, 1) + + assertEquals("a".repeat(WRAPPED_LINE_LENGTH), copied) + assertFalse(copied.contains('\n'), "a wrap point is not a line break") + } + + @Test + fun `a real line break is preserved`() { + val buffer = bufferOf(emulatorWith("one\r\ntwo\r\n")) + + assertEquals("one\ntwo", buffer.text(0, 0, 2, 1)) + } + + @Test + fun `a multi-row selection takes the first row to the edge and the last to the focus`() { + val buffer = bufferOf(emulatorWith("alpha\r\nbravo\r\ncharlie\r\n")) + + assertEquals("pha\nbravo\ncha", buffer.text(2, 0, 2, 2)) + } + + @Test + fun `scrollback rows are readable at negative row indices`() { + val emulator = emulatorWith() + repeat(SCROLLED_OFF_LINES) { index -> emulator.feed("line-$index\r\n") } + + // 40 lines plus the trailing empty one scroll 17 rows off a 24-row screen, so screen row 0 holds + // line-17 and transcript row -1 holds the line just above it. + assertEquals("line-${SCROLLED_OFF_LINES - SCREEN_ROWS}", bufferOf(emulator).text(0, -1, COLUMNS - 1, -1)) + } + + // ── The clamp that keeps the two unguarded stock failures unreachable ──────────────────────────── + + @Test + fun `an off-grid span is clamped instead of throwing out of the stock row walk`() { + val buffer = bufferOf(emulatorWith("hello")) + + // Row -9999: TerminalBuffer.externalToInternalRow throws IllegalArgumentException outside + // -activeTranscriptRows..screenRows. Column 9999: TerminalRow.findStartOfColumn walks mText with + // no bound check for column > mColumns. Both are reachable from a touch outside the grid. + // The clamp turns the span into "the whole measured grid", i.e. the one written row plus the blank + // rows below it — the point being that it RETURNS rather than throwing. + assertEquals("hello", buffer.text(-50, -9999, 9999, 9999).trim()) + } + + @Test + fun `an unmeasured emulator yields no text`() { + // A 0-column emulator cannot be constructed by Termux, so the unmeasured case is "no emulator"; + // proving the clamp refuses rather than divides is still worth pinning. + assertNull(TerminalGridBounds(columns = 0, screenRows = 0, transcriptRows = 0).takeIf { it.isMeasured }) + } + + // ── Long-press word snap ──────────────────────────────────────────────────────────────────────── + + @Test + fun `a long press snaps to the run of non-blank cells around it`() { + val buffer = bufferOf(emulatorWith("cat /etc/hosts")) + + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 6, row = 0), buffer) + + assertEquals(TerminalCell(4, 0), selection?.span?.start) + assertEquals(TerminalCell(13, 0), selection?.span?.end) + assertEquals("/etc/hosts", buffer.textIn(selection!!.span)) + } + + @Test + fun `the snap stops at the start of the row`() { + val buffer = bufferOf(emulatorWith("cat /etc/hosts")) + + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 1, row = 0), buffer) + + assertEquals("cat", buffer.textIn(selection!!.span)) + } + + @Test + fun `the snap spans wide cells`() { + val buffer = bufferOf(emulatorWith("你好 world")) + + // Column 1 is the right half of 你; the run is 你好, i.e. columns 0-3. + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 1, row = 0), buffer) + + assertEquals("你好", buffer.textIn(selection!!.span)) + } + + @Test + fun `a long press on blank space selects just that cell`() { + val buffer = bufferOf(emulatorWith("cat /etc/hosts")) + + val selection = TerminalSelectionWords.runAt(TerminalCell(column = 3, row = 0), buffer) + + assertTrue(selection!!.span.isSingleCell, "a blank cell must not swallow the words either side") + assertEquals(TerminalCell(3, 0), selection.span.start) + } + + @Test + fun `there is nothing to snap to before an emulator is bound`() { + assertNull(TerminalSelectionWords.runAt(TerminalCell(0, 0), bufferOf(null))) + } + + private companion object { + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + + /** Enough output to push rows off the top so there is a transcript to read. */ + const val SCROLLED_OFF_LINES: Int = 40 + + /** Longer than one row, so the emulator sets the row's wrap flag. */ + const val WRAPPED_LINE_LENGTH: Int = 100 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionControllerTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionControllerTest.kt new file mode 100644 index 0000000..49ba45e --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionControllerTest.kt @@ -0,0 +1,249 @@ +package wang.yaojia.webterm.terminalview + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The selection state machine, with the buffer and the clipboard faked so the DECISIONS are asserted + * rather than the framework: when a selection exists, what a drag does to it, what a content scroll does + * to it, and — the §8 rule — that terminal text reaches the clipboard on an explicit copy and at no other + * moment. + */ +class TerminalSelectionControllerTest { + + /** A one-row grid of text, enough to drive the state machine; real extraction is pinned elsewhere. */ + private class FakeBuffer( + private val row: String = "hello world", + private val bounds: TerminalGridBounds? = TerminalGridBounds(COLUMNS, SCREEN_ROWS, TRANSCRIPT_ROWS), + ) : TerminalSelectionBuffer { + var reads = 0 + + override fun bounds(): TerminalGridBounds? = bounds + + override fun textIn(span: TerminalSelectionSpan): String { + reads++ + val bounds = bounds ?: return "" + if (span.start.row != span.end.row) return row + val columns = span.columnsIn(span.start.row, bounds.lastColumn) ?: return "" + return row.substring( + columns.first.coerceIn(0, row.length), + (columns.last + 1).coerceIn(0, row.length), + ).trimEnd() + } + } + + private class FakeClipboard(private val accepts: Boolean = true) : TerminalClipboard { + val puts = mutableListOf() + + override fun put(text: String): Boolean { + puts += text + return accepts + } + } + + private class Recorder { + val changes = mutableListOf() + } + + private fun controller( + buffer: TerminalSelectionBuffer = FakeBuffer(), + clipboard: TerminalClipboard = FakeClipboard(), + recorder: Recorder = Recorder(), + ) = TerminalSelectionController(buffer, clipboard) { recorder.changes += it } + + // ── Begin / extend ─────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a long press starts a selection snapped to the word under it`() { + val recorder = Recorder() + val controller = controller(recorder = recorder) + + assertTrue(controller.beginAt(TerminalCell(column = 2, row = 0))) + + assertTrue(controller.isActive) + assertEquals(TerminalCell(0, 0), controller.selection?.span?.start) + assertEquals(TerminalCell(4, 0), controller.selection?.span?.end, "'hello' is columns 0-4") + assertEquals(listOf(true), recorder.changes) + } + + @Test + fun `a drag moves the focus and leaves the anchor where the press landed`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + + controller.extendTo(TerminalCell(column = 8, row = 0)) + + assertEquals(TerminalCell(0, 0), controller.selection?.span?.start, "the anchor survives the drag") + assertEquals(TerminalCell(8, 0), controller.selection?.span?.end) + } + + @Test + fun `dragging back past the anchor flips the span and keeps the snapped word`() { + val controller = controller() + controller.beginAt(TerminalCell(8, 0)) // snaps to "world", columns 6-10 + controller.extendTo(TerminalCell(column = 1, row = 0)) + + val span = controller.selection?.span + + assertEquals(TerminalCell(1, 0), span?.start) + assertEquals( + TerminalCell(10, 0), + span?.end, + "the whole anchored word stays in — a single-cell anchor would clip 'world' to its 'w'", + ) + } + + @Test + fun `a drag off the grid is clamped, not stored raw`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + + controller.extendTo(TerminalCell(column = 9_999, row = 9_999)) + + assertEquals(TerminalCell(COLUMNS - 1, SCREEN_ROWS - 1), controller.selection?.span?.end) + } + + @Test + fun `nothing can be selected before the grid is measured`() { + val controller = controller(buffer = FakeBuffer(bounds = null)) + + assertFalse(controller.beginAt(TerminalCell(2, 0))) + assertFalse(controller.isActive) + } + + @Test + fun `extending without a selection does nothing`() { + val controller = controller() + + controller.extendTo(TerminalCell(4, 0)) + + assertFalse(controller.isActive) + } + + // ── Copy (the only path to the clipboard) ──────────────────────────────────────────────────────── + + @Test + fun `selecting text puts nothing on the clipboard`() { + // Plan §8: terminal content is untrusted and may be a secret. It reaches the clipboard ONLY on an + // explicit user copy — never on selection, never on drag, never automatically. + val clipboard = FakeClipboard() + val controller = controller(clipboard = clipboard) + + controller.beginAt(TerminalCell(2, 0)) + controller.extendTo(TerminalCell(8, 0)) + controller.clear() + + assertTrue(clipboard.puts.isEmpty()) + } + + @Test + fun `an explicit copy puts exactly the selected text on the clipboard once`() { + val clipboard = FakeClipboard() + val controller = controller(clipboard = clipboard) + controller.beginAt(TerminalCell(2, 0)) + + assertEquals(TerminalCopyResult.COPIED, controller.copy()) + + assertEquals(listOf("hello"), clipboard.puts) + } + + @Test + fun `copying with no selection is refused and touches nothing`() { + val clipboard = FakeClipboard() + val controller = controller(clipboard = clipboard) + + assertEquals(TerminalCopyResult.NOTHING_SELECTED, controller.copy()) + assertTrue(clipboard.puts.isEmpty()) + } + + @Test + fun `a blank selection is never written to the clipboard`() { + val clipboard = FakeClipboard() + val controller = controller(buffer = FakeBuffer(row = " "), clipboard = clipboard) + controller.beginAt(TerminalCell(1, 0)) + + assertEquals(TerminalCopyResult.NOTHING_TO_COPY, controller.copy()) + assertTrue(clipboard.puts.isEmpty(), "clearing the user's clipboard with an empty string is data loss") + } + + @Test + fun `a refused clipboard write is reported instead of being reported as success`() { + val controller = controller(clipboard = FakeClipboard(accepts = false)) + controller.beginAt(TerminalCell(2, 0)) + + assertEquals(TerminalCopyResult.CLIPBOARD_UNAVAILABLE, controller.copy()) + } + + // ── Clear ─────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `clearing drops the selection and reports the change once`() { + val recorder = Recorder() + val controller = controller(recorder = recorder) + controller.beginAt(TerminalCell(2, 0)) + + controller.clear() + + assertFalse(controller.isActive) + assertNull(controller.selection) + assertEquals(listOf(true, false), recorder.changes) + } + + @Test + fun `clearing an empty selection reports nothing`() { + // Load-bearing: the copy affordance is dismissed by clear(), and its own dismissal calls clear() + // back. Without this guard that is an infinite loop. + val recorder = Recorder() + val controller = controller(recorder = recorder) + + controller.clear() + controller.clear() + + assertTrue(recorder.changes.isEmpty()) + } + + // ── Content scrolling under a live selection ──────────────────────────────────────────────────── + + @Test + fun `new output shifts the selection so it keeps pointing at the same text`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + + controller.onContentScrolled(scrolledRows = 3) + + assertEquals(TerminalCell(0, -3), controller.selection?.span?.start) + assertEquals(TerminalCell(4, -3), controller.selection?.span?.end) + } + + @Test + fun `a selection scrolled out of the retained transcript is dropped, not left pointing at nothing`() { + val recorder = Recorder() + val controller = controller(recorder = recorder) + controller.beginAt(TerminalCell(2, 0)) + + controller.onContentScrolled(scrolledRows = TRANSCRIPT_ROWS + 1) + + assertFalse(controller.isActive) + assertEquals(listOf(true, false), recorder.changes) + } + + @Test + fun `a scroll of zero rows is not a change`() { + val controller = controller() + controller.beginAt(TerminalCell(2, 0)) + val before = controller.selection + + controller.onContentScrolled(scrolledRows = 0) + + assertEquals(before, controller.selection) + } + + private companion object { + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + const val TRANSCRIPT_ROWS: Int = 100 + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometryTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometryTest.kt new file mode 100644 index 0000000..4a54352 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionGeometryTest.kt @@ -0,0 +1,117 @@ +package wang.yaojia.webterm.terminalview + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Pixels ↔ cells, and the highlight bands. + * + * The mapping is deliberately the INVERSE of the stock accessors this module already reads + * (`TerminalView.getPointX(col)` = `round(col * fontWidth)`, `getPointY(row)` = `(row - topRow) * + * lineSpacing`), so a tapped cell and its highlight band are the same cell. Stock's own + * `getColumnAndRow` biases the row by `mFontLineSpacingAndAscent` instead, which puts its hit test + * ~20% of a row above the glyph it is drawing — not something to reproduce. + */ +class TerminalSelectionGeometryTest { + + @Test + fun `a touch resolves to the cell under it`() { + val cell = TerminalSelectionGeometry.cellAt( + xPx = 2.5f * CELL_WIDTH_PX, + yPx = 3.5f * CELL_HEIGHT_PX, + metrics = METRICS, + topRow = 0, + ) + + assertEquals(TerminalCell(column = 2, row = 3), cell) + } + + @Test + fun `a touch while scrolled back resolves into the transcript`() { + val cell = TerminalSelectionGeometry.cellAt( + xPx = 0f, + yPx = 0.5f * CELL_HEIGHT_PX, + metrics = METRICS, + topRow = -7, + ) + + assertEquals(TerminalCell(column = 0, row = -7), cell, "row 0 of the viewport is topRow") + } + + @Test + fun `an unmeasured cell box resolves to no cell instead of dividing by zero`() { + assertNull( + TerminalSelectionGeometry.cellAt(10f, 10f, TerminalCellMetrics(0f, 0), topRow = 0), + ) + assertNull( + TerminalSelectionGeometry.cellAt(10f, 10f, TerminalCellMetrics(CELL_WIDTH_PX, 0), topRow = 0), + ) + } + + @Test + fun `a one-row selection is one band, sized to the selected columns`() { + val span = TerminalSelectionSpan.spanning(TerminalCell(2, 1), TerminalCell(4, 1)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0) + + assertEquals( + listOf( + TerminalSelectionRect( + left = 2 * CELL_WIDTH_PX, + top = 1f * CELL_HEIGHT_PX, + right = 5 * CELL_WIDTH_PX, + bottom = 2f * CELL_HEIGHT_PX, + ), + ), + rects, + ) + } + + @Test + fun `a multi-row selection bands the first row to the right edge and the last from the left`() { + val span = TerminalSelectionSpan.spanning(TerminalCell(3, 0), TerminalCell(1, 2)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0) + + assertEquals(3, rects.size) + assertEquals(3 * CELL_WIDTH_PX, rects[0].left) + assertEquals(COLUMNS * CELL_WIDTH_PX, rects[0].right, "the first row runs to the grid edge") + assertEquals(0f, rects[1].left) + assertEquals(COLUMNS * CELL_WIDTH_PX, rects[1].right, "a middle row is fully highlighted") + assertEquals(0f, rects[2].left) + assertEquals(2 * CELL_WIDTH_PX, rects[2].right) + } + + @Test + fun `rows scrolled out of the viewport produce no bands`() { + // A selection made in scrollback, then scrolled away: nothing to paint, and nothing that would + // paint at a negative y over the live screen. + val span = TerminalSelectionSpan.spanning(TerminalCell(0, -40), TerminalCell(5, -38)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = 0) + + assertTrue(rects.isEmpty()) + } + + @Test + fun `a selection straddling the top of the viewport is clipped to the visible rows`() { + val span = TerminalSelectionSpan.spanning(TerminalCell(0, -3), TerminalCell(5, 1)) + + val rects = TerminalSelectionGeometry.highlightRects(span, BOUNDS, METRICS, topRow = -1) + + assertEquals(3, rects.size, "rows -1, 0 and 1 are visible; -3 and -2 are above the viewport") + assertEquals(0f, rects[0].top, "the first visible band starts at the top of the view") + } + + private companion object { + const val CELL_WIDTH_PX: Float = 12f + const val CELL_HEIGHT_PX: Int = 24 + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + + val METRICS = TerminalCellMetrics(CELL_WIDTH_PX, CELL_HEIGHT_PX) + val BOUNDS = TerminalGridBounds(COLUMNS, SCREEN_ROWS, transcriptRows = 100) + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModelTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModelTest.kt new file mode 100644 index 0000000..293ead9 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalSelectionModelTest.kt @@ -0,0 +1,158 @@ +package wang.yaojia.webterm.terminalview + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The selection model on its own: cell coordinates, normalisation, clamping and the per-row column + * ranges. No view, no emulator, no android — this is the part that has to be right before any pixel or + * any clipboard write can be. + */ +class TerminalSelectionModelTest { + + // ── Normalisation ──────────────────────────────────────────────────────────────────────────────── + + @Test + fun `a backwards drag is the same span as the forwards drag`() { + val forwards = selection(cell(2, 1), cell(7, 4)) + val backwards = selection(cell(7, 4), cell(2, 1)) + + assertEquals(forwards.span, backwards.span) + assertEquals(cell(2, 1), forwards.span.start) + assertEquals(cell(7, 4), forwards.span.end) + } + + @Test + fun `a backwards drag inside one row swaps the columns only`() { + val span = selection(cell(9, 3), cell(4, 3)).span + + assertEquals(cell(4, 3), span.start) + assertEquals(cell(9, 3), span.end) + } + + @Test + fun `ordering is row-major, so a lower row wins even with a bigger column`() { + // Dragging up-and-right must not be read as a forwards selection: the row decides. + val span = selection(cell(0, 5), cell(70, 2)).span + + assertEquals(cell(70, 2), span.start) + assertEquals(cell(0, 5), span.end) + } + + @Test + fun `a one-cell selection is a one-cell span`() { + val span = selection(cell(5, 5), cell(5, 5)).span + + assertEquals(span.start, span.end) + assertTrue(span.isSingleCell) + } + + // ── Per-row column ranges (what both the highlight and the extraction use) ─────────────────────── + + @Test + fun `the first row runs to the last column and the last row starts at column zero`() { + val span = selection(cell(3, 0), cell(6, 2)).span + + assertEquals(3..LAST_COLUMN, span.columnsIn(0, LAST_COLUMN)) + assertEquals(0..LAST_COLUMN, span.columnsIn(1, LAST_COLUMN), "a middle row is fully selected") + assertEquals(0..6, span.columnsIn(2, LAST_COLUMN)) + } + + @Test + fun `a single-row span uses both of its columns`() { + val span = selection(cell(3, 4), cell(6, 4)).span + + assertEquals(3..6, span.columnsIn(4, LAST_COLUMN)) + } + + @Test + fun `rows outside the span have no column range`() { + val span = selection(cell(3, 2), cell(6, 4)).span + + assertNull(span.columnsIn(1, LAST_COLUMN)) + assertNull(span.columnsIn(5, LAST_COLUMN)) + } + + // ── Clamping (the reason an off-grid touch cannot crash the buffer read) ───────────────────────── + + @Test + fun `an off-grid touch clamps into the grid`() { + // TerminalRow.findStartOfColumn walks mText with no bound check for column > mColumns, and + // TerminalBuffer.externalToInternalRow THROWS for a row outside the transcript. Clamping here is + // what keeps both unreachable. + val span = selection(cell(-4, -900), cell(500, 99)).span.clampedTo(BOUNDS) + + assertEquals(cell(0, -TRANSCRIPT_ROWS), span?.start) + assertEquals(cell(LAST_COLUMN, SCREEN_ROWS - 1), span?.end) + } + + @Test + fun `an in-grid span is left alone by the clamp`() { + val span = selection(cell(1, -2), cell(9, 3)).span + + assertEquals(span, span.clampedTo(BOUNDS)) + } + + @Test + fun `an unmeasured grid clamps to nothing rather than to cell zero`() { + val span = selection(cell(1, 1), cell(2, 2)).span + + assertNull(span.clampedTo(TerminalGridBounds(columns = 0, screenRows = 0, transcriptRows = 0))) + } + + // ── The anchor is a SPAN, because a long press snaps to a word ─────────────────────────────────── + + @Test + fun `a word-anchored selection starts as exactly that word`() { + val selection = TerminalSelection.anchoredOn(cell(4, 1), cell(13, 1)) + + assertEquals(cell(4, 1), selection.span.start) + assertEquals(cell(13, 1), selection.span.end) + assertEquals(cell(13, 1), selection.focus, "the focus starts at the end the drag continues from") + } + + @Test + fun `dragging back past a word-anchored selection keeps the whole word`() { + // The editor behaviour: the word you long-pressed stays selected however you drag. Anchoring on a + // single cell instead would collapse "charlie" to its first character the moment the finger moved + // above it. + val dragged = TerminalSelection.anchoredOn(cell(0, 2), cell(6, 2)).withFocus(cell(2, 0)) + + assertEquals(cell(2, 0), dragged.span.start) + assertEquals(cell(6, 2), dragged.span.end) + } + + @Test + fun `a focus inside the anchor changes nothing`() { + val anchored = TerminalSelection.anchoredOn(cell(4, 1), cell(13, 1)) + + assertEquals(anchored.span, anchored.withFocus(cell(8, 1)).span) + } + + // ── Content scrolling ─────────────────────────────────────────────────────────────────────────── + + @Test + fun `new output shifts the selection up by the scrolled rows`() { + // External row indices move when the screen scrolls: the content the user selected is one row + // higher after one new line. Without this the highlight (and the copy) would silently drift. + val moved = selection(cell(2, 5), cell(4, 6)).movedBy(-3) + + assertEquals(cell(2, 2), moved.span.start) + assertEquals(cell(4, 3), moved.span.end) + } + + private companion object { + const val COLUMNS: Int = 80 + const val SCREEN_ROWS: Int = 24 + const val TRANSCRIPT_ROWS: Int = 100 + const val LAST_COLUMN: Int = COLUMNS - 1 + val BOUNDS = TerminalGridBounds(COLUMNS, SCREEN_ROWS, TRANSCRIPT_ROWS) + + fun cell(column: Int, row: Int) = TerminalCell(column, row) + + /** A press at [from] with the finger now at [to] — the one-cell-anchor case. */ + fun selection(from: TerminalCell, to: TerminalCell) = TerminalSelection.at(from).withFocus(to) + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt index 29050fc..9c1052c 100644 --- a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/WebTermTerminalViewClientTest.kt @@ -1,6 +1,9 @@ package wang.yaojia.webterm.terminalview import android.view.KeyEvent +import android.view.MotionEvent +import io.mockk.every +import io.mockk.mockk import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -26,6 +29,7 @@ class WebTermTerminalViewClientTest { var taps = 0 var sizes = mutableListOf>() var keysOfferedToApp = 0 + val longPresses = mutableListOf>() fun setCursorApplicationMode(on: Boolean) { modes = TerminalCursorModes(cursorKeysApplication = on, keypadApplication = false) @@ -45,6 +49,10 @@ class WebTermTerminalViewClientTest { taps++ } + override fun onLongPressAt(xPx: Float, yPx: Float) { + longPresses += xPx to yPx + } + override fun onViewSize(widthPx: Int, heightPx: Int) { sizes += widthPx to heightPx } @@ -136,6 +144,20 @@ class WebTermTerminalViewClientTest { ) } + @Test + fun `a long press starts the first-party selection at the pressed pixels`() { + // Consuming the event is what keeps the stock mode unreachable; forwarding the coordinates is what + // turns the consumed press into the replacement feature instead of a discarded gesture. + val sink = FakeSink() + val event = mockk(relaxed = true) + every { event.x } returns PRESS_X_PX + every { event.y } returns PRESS_Y_PX + + assertTrue(WebTermTerminalViewClient(sink).onLongPress(event)) + + assertEquals(listOf(PRESS_X_PX to PRESS_Y_PX), sink.longPresses) + } + @Test fun `a tap raises the keyboard and pinch-zoom is declined`() { val sink = FakeSink() @@ -170,4 +192,9 @@ class WebTermTerminalViewClientTest { assertFalse(client.readShiftKey()) assertFalse(client.readFnKey()) } + + private companion object { + const val PRESS_X_PX: Float = 42f + const val PRESS_Y_PX: Float = 96f + } } From 14f28e9d66bf57441686921fc019c1c94a9d860c Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 15:38:10 +0200 Subject: [PATCH 10/10] docs(android): record what the device run proved and what is still open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The correction section written earlier today said A34/A35 had no code and device QA was ~0%. Both were true when written and are not any more, so they are marked resolved rather than left to mislead the next reader — with the instrumented invocation recorded (the emulator needs ALLOWED_ORIGINS=http://10.0.2.2:, because allowed origins are derived from NIC IPs and 10.0.2.2 is not one). What stays open is stated plainly: S2 needs two physical handsets under Doze, the rest of DEVICE_QA_CHECKLIST has no automated cover, and one instrumented test kills the process when driven without host arguments on a heavily-recycled emulator while passing reproducibly under the documented invocation — which is recorded as unresolved rather than explained away. --- android/PROGRESS_ANDROID.md | 31 +++++++++++++++++++++++++------ docs/PROGRESS_LOG.md | 7 ++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/android/PROGRESS_ANDROID.md b/android/PROGRESS_ANDROID.md index 8abad86..9914597 100644 --- a/android/PROGRESS_ANDROID.md +++ b/android/PROGRESS_ANDROID.md @@ -437,12 +437,31 @@ with Tink AEAD under an AndroidKeyStore key, **fail-closed** (a seal failure per than falling back to plaintext), and `allowBackup=false` keeps a 30-day shell credential out of Google Drive and D2D transfer. -**Still not done — do not read the section below as saying otherwise:** - - **A34 / A35 have no code.** There is no `:app` androidTest source set and no benchmark module; the - AW6 entry marking them `[x]` reflects them being converted into checklist bullets, not implemented. - - **Device QA remains ~0%.** One emulator smoke screenshot of the pairing screen is the only on-device - evidence in the repo. Every fix above is JVM-verified only. - - Release signing, versioning and minification; the S2 FCM real-handset spike; copy-out via selection. +**RESOLVED LATER THE SAME DAY** (this list was accurate when written; it is not any more): + - **A34 / A35 now exist and RUN.** `:app/src/androidTest/**` holds 67 instrumented tests — the four + crash regressions, the A34 E2E set (attach→output, ring-buffer replay, spawn failure via a second + deliberately-broken server, a gate resolved through `POST /hook/decision`, and the F9 bad-Origin + rejection), and the Compose UI tests plan §7 named — plus `macrobenchmark/` for A35. All 67 execute + green on `emulator-5554` against this repo's own server. Reaching the host needs + `ALLOWED_ORIGINS=http://10.0.2.2:` (the server derives allowed origins from NIC IPs, and + 10.0.2.2 is the emulator's alias for the host, not an interface address). + - **Device QA is no longer ~0%.** The blocker fixes are device-verified, not just JVM-verified. + - Release signing, versioning and minification are done: `assembleRelease` runs R8 and now REFUSES to + emit an unsigned artifact instead of quietly producing one. + - **Copy-out is done** — a first-party selection layer over `TerminalBuffer.getSelectedText` + + `ClipboardManager`, since the stock ActionMode's Copy handler dereferences the absent session. + - The six audit leftovers are closed too: silent certificate rotation (+ `/device/:id/recover`), the W2 + inject-queue routes, unread-watermark persistence, host-removal UI, and `/config/ui` finally honoured + (fail-closed). + +**Still genuinely open:** + - **S2** — the FCM real-handset delivery spike. Needs ≥2 physical devices under Doze/OEM battery + managers; an emulator cannot answer it. + - One instrumented test (`wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch`) was seen to kill the + test process when the suite was driven WITHOUT the host arguments on a heavily-recycled emulator. It + passes reproducibly under the documented invocation on a clean install. Re-check on real hardware + before trusting it either way. + - Everything else device-observable in `DEVICE_QA_CHECKLIST.md` that no automated test covers. --- diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 9eb9779..b2a487d 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -47,7 +47,12 @@ - **设备 QA 仍约 0%** —— 仓库里唯一的真机证据是一张配对页的模拟器截图。上面每一条修复都只有 JVM 级验证。 - release 签名/版本号/minify;S2 FCM 真机投递;复制功能。 - `android/PROGRESS_ANDROID.md` 顶部已加"CORRECTION"段落说明上述全部。 -- **下一步**: 模拟器(AVD `webterm`,已启动)上跑 instrumented 回归 —— 尤其是这次修掉的三个崩溃,那正是 JVM 测试抓不到、也正是本次修复存在的理由。 +- **后续同日完成(本条目最初写的"仍未完成"已不成立)**: + - **设备上真跑起来了**: `:app/src/androidTest/**` 67 个 instrumented 测试 + `macrobenchmark/`,在 `emulator-5554` 上对本仓库自己的服务器**全绿(67/0)**。含四个崩溃回归、A34 E2E(attach→output、ring buffer 重放、用第二个故意配坏的服务器测 spawn 失败、`POST /hook/decision` 解 gate、**F9 坏 Origin 拒绝**)、以及 plan §7 点名的 Compose 测试。模拟器要打通宿主机必须 `ALLOWED_ORIGINS=http://10.0.2.2:` —— 服务端的 allowedOrigins 从网卡 IP 派生,而 10.0.2.2 是模拟器对宿主机的别名、不是任何网卡地址。 + - **设备上又抓出第四个崩溃**: `onDraw` → `TerminalRow.getStyle` 的 `AIOOBE`,而 `PROGRESS_ANDROID.md` 原本把它写成"Accepted (not a defect) — 撕裂读会自我修正"。两半都是错的:它是进程直杀;而且"matches upstream Termux"也不成立 —— 上游 `append` 由 `MainThreadHandler` 在主线程调用,压根没有并发读者。根因是 `TerminalEmulator.resize` **先**发布新 `mColumns`、**后**才重新分配行数组,`length == index` 那个"可疑的一致性"是**扩容的确定性签名**。**而且这个竞态以前是休眠的** —— resize 从不发生,列数就不变,没有宽度差可撕;是本次把 resize 修好才让它变可达。修法:emulator mutation 全部挪到渲染线程(与上游一致),单写者模型不变。已先复现成 JVM 测试,所以这条回归以后不需要设备就能守住。 + - **审计遗留 6 项 + 复制功能全部关闭**: 证书静默轮换(顺带修掉一个会让**每次续期都失败**的 `renew()` 解码 bug,并指出 iOS 有个"只轮换一次"的潜在缺陷 Android 通过从叶证书反推 `renewAfter` 避开了)、`/device/:id/recover`(走非 mTLS,因为过期证书没法给自己的恢复做认证)、W2 inject-queue 三条路由、未读水位持久化、host 移除 UI、`/config/ui` 被尊重且**fail-closed**、以及基于 `TerminalBuffer.getSelectedText` 自建的选择/复制层(stock ActionMode 的 Copy 处理器解引用空 session,所以不能恢复它)。 + - **最终门禁**: `./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :macrobenchmark:assembleBenchmark koverVerify` → BUILD SUCCESSFUL,**1150 JVM 测试 / 0 失败**(基线 617);instrumented **67/0**。commit `3e5cfdc`→`390dd11` 共 9 个。 +- **仍然开放**: **S2**(FCM 真机投递,需要 ≥2 台物理设备在 Doze/厂商省电下验证,模拟器答不了);`DEVICE_QA_CHECKLIST.md` 里没有自动化覆盖的其余设备可观测项;一个 instrumented 测试在**不传 host 参数**且模拟器被反复安装卸载后会杀掉测试进程,按文档化的调用方式+干净安装则稳定通过 —— 上真机再判。 ### 🌿 [x] w6 项目详情 Git 面板 — G1–G7 全部完成(2026-07-29,worktree `project-detail-git-mockup`)