feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled

Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated):
:wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec),
:session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client,
:client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework:
:app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux
terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless),
:host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink).

Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading
X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed
rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver,
Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10);
config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers.

Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all
framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35,
S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no
emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial
cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
This commit is contained in:
Yaojia Wang
2026-07-10 16:41:21 +02:00
parent 542fde9580
commit e254918b1c
159 changed files with 20114 additions and 73 deletions

View File

@@ -0,0 +1,29 @@
package wang.yaojia.webterm.terminalview
import java.util.Locale
/**
* The http/https-only allowlist for terminal link taps (plan §6.5: "URL detection → `Intent(ACTION_VIEW)`
* with an http/https-only allowlist"). Terminal output is attacker-influenced (a session can print any
* escape/URL), so before the view launches an `ACTION_VIEW` intent it must reject every other scheme —
* `file:`, `content:`, `intent:`, `javascript:`, custom app schemes — which could otherwise exfiltrate
* local files or trigger a deep link.
*
* Pure + JVM-testable; the view layer calls [isAllowedUrl] before building the intent.
*/
public object LinkPolicy {
private val ALLOWED_SCHEMES = setOf("http", "https")
/**
* True iff [url] carries an explicit `http`/`https` scheme (case-insensitive). A missing or foreign
* scheme is rejected — no scheme-relative or bare-host guessing (that is how a `file:`/`intent:`
* payload slips through).
*/
public fun isAllowedUrl(url: String): Boolean {
val separator = url.indexOf(':')
if (separator <= 0) return false
val scheme = url.substring(0, separator).lowercase(Locale.ROOT)
return scheme in ALLOWED_SCHEMES
}
}

View File

@@ -0,0 +1,42 @@
package wang.yaojia.webterm.terminalview
import com.termux.terminal.TerminalEmulator
import com.termux.terminal.TerminalSession
import com.termux.terminal.TerminalSessionClient
/**
* A no-op [TerminalSessionClient] handed to the [TerminalEmulator] constructor.
*
* Termux's `TerminalEmulator` calls `mClient` on a handful of rare escape-sequence paths (DECRQM,
* termcap/terminfo queries, some SGR/OSC diagnostics) WITHOUT a null-check, so the emulator must be
* built with a non-null client or those sequences would crash the parser. We therefore supply a client
* that swallows every callback: this class is NOT a subprocess bridge (there is no [TerminalSession] —
* the callback parameters are always ignored) and it logs NOTHING (terminal bytes are attacker-
* influenced; the parser's diagnostic strings must never reach logcat).
*
* Title changes do NOT arrive here — the emulator routes OSC 0/2 titles through
* `TerminalOutput.titleChanged`, which [RemoteTerminalSession] handles. This client's [onTitleChanged]
* is intentionally inert.
*/
internal class NoOpTerminalSessionClient : TerminalSessionClient {
override fun onTextChanged(changedSession: TerminalSession?) = Unit
override fun onTitleChanged(changedSession: TerminalSession?) = Unit
override fun onSessionFinished(finishedSession: TerminalSession?) = Unit
override fun onCopyTextToClipboard(session: TerminalSession?, text: String?) = Unit
override fun onPasteTextFromClipboard(session: TerminalSession?) = Unit
override fun onBell(session: TerminalSession?) = Unit
override fun onColorsChanged(session: TerminalSession?) = Unit
override fun onTerminalCursorStateChange(state: Boolean) = Unit
/** Default block cursor; the on-screen view owns real cursor styling. */
override fun getTerminalCursorStyle(): Int = TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK
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
}

View File

@@ -0,0 +1,308 @@
package wang.yaojia.webterm.terminalview
import android.util.Log
import com.termux.terminal.KeyHandler
import com.termux.terminal.TerminalEmulator
import com.termux.terminal.TerminalOutput
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
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
/**
* The FORK of Termux's `TerminalSession` (plan §6.1) — a terminal session with **no local subprocess
* and no JNI**. It owns a [TerminalEmulator] (the VT100/xterm parser + `TerminalBuffer` scrollback ring)
* that is driven entirely by bytes arriving over the WebSocket, and routes emulator-originated bytes
* (DA/DSR replies, mouse reports, bracketed-paste wrappers) back out through [engineSend].
*
* This is the single seam A21 wires the engine to: `output: Flow<ByteArray>` → [feedRemote];
* `sendInput`/`resize` ← [writeInput]/[updateSize] via [engineSend]. It depends ONLY on
* `: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).
*
* 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).
* 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.
* @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).
*/
public class RemoteTerminalSession(
private val engineSend: (ClientMessage) -> Unit,
initialCols: Int = DEFAULT_COLS,
initialRows: Int = DEFAULT_ROWS,
transcriptRows: Int = TRANSCRIPT_ROWS,
private val onTitleChanged: (String) -> Unit = {},
private val onBell: () -> Unit = {},
appendDispatcher: CoroutineDispatcher? = null,
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
) {
/** Emulator-originated output sink + title/clipboard/bell delegate. Runs on the confined thread. */
private val termOutput: TerminalOutput = object : TerminalOutput() {
override fun write(data: ByteArray?, offset: Int, count: Int) {
if (data == null || count <= 0) return
// Emulator reply bytes (DA/DSR/mouse) are ASCII; the engine re-encodes verbatim. Mirrors
// the web/iOS clients treating input as a string (invariant #9 — no content filtering).
engineSend(ClientMessage.Input(String(data, offset, count, Charsets.UTF_8)))
}
override fun titleChanged(oldTitle: String?, newTitle: String?) {
onTitleChanged(newTitle ?: "")
}
// OSC 52 host-clipboard writes are DECLINED (plan §6.5) — never leak session output to the
// device clipboard without an explicit user copy.
override fun onCopyTextToClipboard(text: String?) = Unit
override fun onPasteTextFromClipboard() = Unit
override fun onBell(): Unit = this@RemoteTerminalSession.onBell()
override fun onColorsChanged() = Unit
}
/** The VT parser + scrollback buffer. Public so the on-screen view binds it and tests read it. */
public val emulator: TerminalEmulator =
TerminalEmulator(termOutput, initialCols, initialRows, transcriptRows, NoOpTerminalSessionClient())
// ── Confinement plumbing (all mutable state below is touched ONLY on the confined thread) ────────
private val ownedExecutor = if (appendDispatcher == null) {
Executors.newSingleThreadExecutor { r -> Thread(r, "webterm-term-append").apply { isDaemon = true } }
} else {
null
}
private val confinedDispatcher: CoroutineDispatcher =
appendDispatcher ?: ownedExecutor!!.asCoroutineDispatcher()
private val scope = CoroutineScope(SupervisorJob() + confinedDispatcher)
private val commands = Channel<TerminalCommand>(Channel.UNLIMITED)
/** Queued output that arrived before the view bound; flushed in submission order on bind (§6.2). */
private val pendingOutput = ArrayDeque<ByteArray>()
private var bound = false
private var screenUpdateSink: (() -> Unit)? = null
private var lastSentDims: TerminalGridSize? = null
init {
scope.launch { consumeCommands() }
}
// ── Inbound: remote output → emulator (off-main, chunked) ────────────────────────────────────────
/**
* 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
* (§6.2); the `ESC[0m` replay prefix is passed through, never stripped.
*/
public fun feedRemote(bytes: ByteArray) {
if (bytes.isEmpty()) return
commands.trySend(TerminalCommand.Feed(bytes))
}
/**
* 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.
*/
public fun updateSize(cols: Int, rows: Int) {
if (cols < TerminalGridMath.MIN_DIMENSION || rows < TerminalGridMath.MIN_DIMENSION) return
commands.trySend(TerminalCommand.Resize(cols, rows))
}
// ── Outbound: typed / key-bar bytes → wire ───────────────────────────────────────────────────────
/** Typed / key-bar bytes → `engineSend(Input(data))`, verbatim (invariant #9). Any thread. */
public fun writeInput(data: String) {
if (data.isEmpty()) return
engineSend(ClientMessage.Input(data))
}
/**
* The byte sequence for a hardware key, deferring to Termux's [KeyHandler] so DECCKM
* (`cursorKeysApplication`) still emits `ESC O A` (not `ESC [ A`) for vim/htop (plan §6.3). The
* caller (view) sends the result via [writeInput]. Returns null when the key maps to nothing.
*/
public fun keyBytes(keyCode: Int, keyMode: Int): String? =
KeyHandler.getCode(
keyCode,
keyMode,
emulator.isCursorKeysApplicationMode,
emulator.isKeypadApplicationMode,
)
// ── 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.
*/
public fun attachView(view: RemoteTerminalView) {
view.session = this
bind(publishEmulator = { view.bindEmulator(emulator) }) { view.requestScreenUpdate() }
}
/** Detach the view (rotation / real background). The emulator + scrollback survive in this holder. */
public fun detachView() {
commands.trySend(TerminalCommand.Unbind)
}
/**
* 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.
*/
internal fun bind(publishEmulator: () -> Unit = {}, onScreenUpdated: () -> Unit) {
commands.trySend(TerminalCommand.Bind(publishEmulator, onScreenUpdated))
}
/** Await-free shutdown: stop the consumer, cancel the scope, release the owned thread. */
public fun close() {
commands.close()
scope.cancel()
ownedExecutor?.shutdownNow()
}
// ── The single confined consumer (one writer to the emulator) ────────────────────────────────────
private suspend fun consumeCommands() {
for (command in commands) {
try {
when (command) {
is TerminalCommand.Feed -> onFeed(command.bytes)
is TerminalCommand.Resize -> onResize(command.cols, command.rows)
is TerminalCommand.Bind -> onBind(command.publishEmulator, command.onScreenUpdated)
TerminalCommand.Unbind -> {
bound = false
screenUpdateSink = null
}
}
} catch (e: CancellationException) {
throw e // never swallow cooperative cancellation — let the scope tear down cleanly
} catch (t: Throwable) {
// A malformed / hostile escape byte must NOT kill the single consumer and permanently
// freeze all further output. Survive the one bad command and keep draining. Log the
// throwable TYPE only — never its message or the command bytes, which may carry
// terminal/session content (no secrets leaked).
Log.w(LOG_TAG, "dropping a terminal command that threw: ${t.javaClass.simpleName}")
}
}
}
private suspend fun onFeed(bytes: ByteArray) {
if (!bound) {
pendingOutput.addLast(bytes)
return
}
appendChunked(bytes)
postScreenUpdate()
}
private suspend fun onBind(publishEmulator: () -> Unit, onScreenUpdated: () -> Unit) {
screenUpdateSink = onScreenUpdated
bound = true
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).
withContext(mainDispatcher) {
publishEmulator()
onScreenUpdated()
}
}
private fun onResize(cols: Int, rows: Int) {
val next = TerminalGridSize(cols, rows)
if (next == lastSentDims) return
emulator.resize(cols, rows) // local buffer reflow (confined-thread single writer)
engineSend(ClientMessage.Resize(cols, rows)) // → server SIGWINCH
lastSentDims = next
}
/**
* 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).
* 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.
*/
private suspend fun appendChunked(bytes: ByteArray) {
var offset = 0
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)
offset = end
if (offset < bytes.size) yield()
}
}
private suspend fun postScreenUpdate() {
val sink = screenUpdateSink ?: return
withContext(mainDispatcher) { sink() }
}
// ── Test-only inspectors (confined state read after the scheduler is idle) ───────────────────────
/** The rendered screen + scrollback text, for the S1 seam assertion. */
internal fun screenTextForTest(): String = emulator.screen.transcriptText
/** Count of not-yet-flushed pending chunks, for the pendingOutput-ordering assertion. */
internal fun pendingChunkCountForTest(): Int = pendingOutput.size
/** The last dims actually sent to the server, for the resize-dedup assertion. */
internal fun lastSentDimsForTest(): TerminalGridSize? = lastSentDims
public companion object {
/** Server hardcodes 80×24 on attach (`src/server.ts`); the real grid follows via `resize`. */
public const val DEFAULT_COLS: Int = 80
public const val DEFAULT_ROWS: Int = 24
/** Local scroll-up history while connected; authoritative history is the server's ring replay. */
public const val TRANSCRIPT_ROWS: Int = 10_000
/** Append slice size — bounds one `append` call so a multi-MB replay never blocks. */
public const val APPEND_CHUNK_BYTES: Int = 4096
/** Logcat tag for the survive-a-bad-command path (logs the throwable type only, never content). */
private const val LOG_TAG: String = "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 Bind(val publishEmulator: () -> Unit, val onScreenUpdated: () -> Unit) : TerminalCommand
data object Unbind : TerminalCommand
}

View File

@@ -0,0 +1,56 @@
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].
*
* ### 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 [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.
*
* 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.
*/
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)
/** The forked session backing this view. Set by [RemoteTerminalSession.attachView]. */
public var session: RemoteTerminalSession? = null
/**
* 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).
*/
public var onKeyCommand: ((keyCode: Int, event: KeyEvent) -> Boolean)? = null
/** Layout-change outlet (A17/A21 install point) — wires real font metrics into the resize path. */
public var onViewSizeChanged: ((widthPx: Int, heightPx: Int) -> Unit)? = null
/** Point the stock renderer at the forked emulator (bypasses the process-forking `attachSession`). */
public fun bindEmulator(emulator: TerminalEmulator) {
terminalView.mEmulator = emulator
}
/** Invalidate/redraw the stock view for the current emulator state (posted on the main thread). */
public fun requestScreenUpdate() {
terminalView.onScreenUpdated()
}
}

View File

@@ -0,0 +1,57 @@
package wang.yaojia.webterm.terminalview
import kotlin.math.floor
/**
* The cols×rows grid a terminal viewport resolves to for a given pixel size and font metric — the
* pure, JVM-unit-testable half of the resize path (plan §6.4, risk R5: "cols×rows drift — Android
* font metrics ≠ SwiftTerm"). Extracted as a free function so the arithmetic is verified against known
* metrics WITHOUT a device: the real `mFontWidth` / `mFontLineSpacing` come from Termux's
* `TerminalRenderer` at runtime (they need a real `android.graphics.Paint`), but the formula that turns
* them into a grid must never drift from the server/web/iOS contract.
*
* ```
* cols = max(1, floor((viewW - 2*hPad) / mFontWidth))
* rows = max(1, floor((viewH - 2*vPad) / mFontLineSpacing))
* ```
*
* Non-positive pre-layout dimensions (a view measured at 0×0 before its first layout pass) yield
* `null` — the caller drops them and never sends a bogus `resize` (plan §6.4: "drop non-positive
* pre-layout dims").
*/
public data class TerminalGridSize(val cols: Int, val rows: Int)
public object TerminalGridMath {
/** The smallest usable grid — the server hardcodes 80×24 on attach; we never send below 1×1. */
public const val MIN_DIMENSION: Int = 1
/**
* Resolve [viewWidthPx]×[viewHeightPx] (minus symmetric padding) into a terminal grid using the
* cell metrics [fontWidthPx] (`TerminalRenderer.mFontWidth`, a float) and [fontLineSpacingPx]
* (`TerminalRenderer.mFontLineSpacing`, an int).
*
* Returns `null` when any input is non-positive OR the usable area after padding is non-positive —
* i.e. the view has not been laid out yet, so there is no valid grid to send.
*/
public fun computeGridSize(
viewWidthPx: Int,
viewHeightPx: Int,
horizontalPaddingPx: Int,
verticalPaddingPx: Int,
fontWidthPx: Float,
fontLineSpacingPx: Int,
): TerminalGridSize? {
// Fail fast on any pre-layout / degenerate metric — never emit a bogus resize.
if (viewWidthPx <= 0 || viewHeightPx <= 0) return null
if (fontWidthPx <= 0f || fontLineSpacingPx <= 0) return null
val usableWidth = viewWidthPx - 2 * horizontalPaddingPx
val usableHeight = viewHeightPx - 2 * verticalPaddingPx
if (usableWidth <= 0 || usableHeight <= 0) return null
val cols = floor(usableWidth / fontWidthPx).toInt().coerceAtLeast(MIN_DIMENSION)
val rows = (usableHeight / fontLineSpacingPx).coerceAtLeast(MIN_DIMENSION)
return TerminalGridSize(cols, rows)
}
}

View File

@@ -0,0 +1,64 @@
package wang.yaojia.webterm.terminalview
import android.view.KeyEvent
import com.termux.terminal.KeyHandler
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.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* DECCKM (plan §6.3): with `cursorKeysApplication` set (`ESC[?1h`), arrow keys must emit `ESC O A`
* (SS3) not `ESC [ A` (CSI) — hardcoding CSI would break vim/htop. We defer to Termux's [KeyHandler]
* and drive it from the emulator's live mode bit. `KeyHandler.getCode` runs headless because its
* `KEYCODE_*` cases are inlined compile-time constants (no runtime `android.view.KeyEvent` link).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class DecckmKeyTest {
private val noModifiers = 0
@Test
fun `KeyHandler emits SS3 for arrows under cursor-keys-application, CSI otherwise`() {
// Normal (DECCKM off) → CSI.
assertEquals("\u001b[A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, noModifiers, false, false))
assertEquals("\u001b[B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, noModifiers, false, false))
assertEquals("\u001b[C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, noModifiers, false, false))
assertEquals("\u001b[D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, noModifiers, false, false))
// Application-cursor-keys (DECCKM on) → SS3.
assertEquals("\u001bOA", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, noModifiers, true, false))
assertEquals("\u001bOB", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, noModifiers, true, false))
assertEquals("\u001bOC", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, noModifiers, true, false))
assertEquals("\u001bOD", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, noModifiers, true, false))
}
@Test
fun `session keyBytes tracks the emulator's live DECCKM mode`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val session = RemoteTerminalSession(
engineSend = {},
appendDispatcher = dispatcher,
mainDispatcher = dispatcher,
)
session.bind {}
// Before any mode change: CSI.
assertFalse(session.emulator.isCursorKeysApplicationMode)
assertEquals("\u001b[A", session.keyBytes(KeyEvent.KEYCODE_DPAD_UP, noModifiers))
// ESC[?1h sets DECCKM (application cursor keys) — vim/htop turn this on.
session.feedRemote("\u001b[?1h".toByteArray(Charsets.UTF_8))
advanceUntilIdle()
assertTrue(session.emulator.isCursorKeysApplicationMode, "ESC[?1h must set DECCKM")
assertEquals("\u001bOA", session.keyBytes(KeyEvent.KEYCODE_DPAD_UP, noModifiers))
session.close()
advanceUntilIdle()
}
}

View File

@@ -0,0 +1,33 @@
package wang.yaojia.webterm.terminalview
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The link allowlist (plan §6.5): only http/https URLs from attacker-influenced terminal output may
* become an `ACTION_VIEW` intent. Everything else — `file:`, `content:`, `intent:`, `javascript:`,
* custom app schemes, scheme-less text — is rejected so a printed payload can't exfiltrate a local file
* or fire a deep link.
*/
class LinkPolicyTest {
@Test
fun `http and https are allowed, case-insensitively`() {
assertTrue(LinkPolicy.isAllowedUrl("http://example.com"))
assertTrue(LinkPolicy.isAllowedUrl("https://example.com/path?q=1"))
assertTrue(LinkPolicy.isAllowedUrl("HTTPS://EXAMPLE.COM"))
}
@Test
fun `non-web and scheme-less URLs are rejected`() {
assertFalse(LinkPolicy.isAllowedUrl("file:///etc/passwd"))
assertFalse(LinkPolicy.isAllowedUrl("content://media/external"))
assertFalse(LinkPolicy.isAllowedUrl("intent://scan#Intent;scheme=x;end"))
assertFalse(LinkPolicy.isAllowedUrl("javascript:alert(1)"))
assertFalse(LinkPolicy.isAllowedUrl("myapp://open"))
assertFalse(LinkPolicy.isAllowedUrl("example.com"))
assertFalse(LinkPolicy.isAllowedUrl(""))
assertFalse(LinkPolicy.isAllowedUrl(":nohost"))
}
}

View File

@@ -0,0 +1,108 @@
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
/**
* The pendingOutput invariant (plan §6.2): output that arrives BEFORE the view binds (a ring replay can
* land first) is queued in submission order and flushed the instant the view binds — nothing dropped,
* nothing reordered, and the `ESC[0m` soft-reset replay prefix is passed through, never stripped.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class PendingOutputTest {
@Test
fun `output before bind is queued then flushed in submission order`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val session = RemoteTerminalSession(
engineSend = {},
appendDispatcher = dispatcher,
mainDispatcher = dispatcher,
)
// Two chunks arrive BEFORE any view binds. The first carries the ESC[0m replay prefix.
session.feedRemote("\u001b[0mAAA".toByteArray(Charsets.UTF_8))
session.feedRemote("BBB".toByteArray(Charsets.UTF_8))
advanceUntilIdle()
// Nothing appended yet — both chunks are held.
assertEquals(2, session.pendingChunkCountForTest(), "both chunks should be pending pre-bind")
assertEquals("", session.screenTextForTest().trim(), "emulator must be untouched pre-bind")
// Binding flushes in submission order: AAA then BBB, ESC[0m preserved (produces no glyphs).
session.bind {}
advanceUntilIdle()
assertEquals(0, session.pendingChunkCountForTest(), "queue drained on bind")
assertEquals("AAABBB", session.screenTextForTest().trim(), "flush order must be AAA then BBB")
session.close()
advanceUntilIdle()
}
@Test
fun `emulator is published to the renderer only after pending output is in the buffer`() = runTest {
// FIX 1 (bind-time data race): the emulator must NOT be handed to the stock renderer until the
// initial pendingOutput flush has COMPLETED — otherwise the UI-thread draw can read
// TerminalBuffer while the confined thread is still appending the ring replay.
val dispatcher = StandardTestDispatcher(testScheduler)
val session = RemoteTerminalSession(
engineSend = {},
appendDispatcher = dispatcher,
mainDispatcher = dispatcher,
)
// A ring-replay chunk lands BEFORE any view binds — it is queued, buffer untouched.
session.feedRemote("\u001b[0mHELLO".toByteArray(Charsets.UTF_8))
advanceUntilIdle()
assertEquals(1, session.pendingChunkCountForTest(), "chunk should be pending pre-bind")
assertEquals("", session.screenTextForTest().trim(), "buffer must be untouched pre-bind")
// publishEmulator stands in for `view.bindEmulator(emulator)` (exposing the buffer to the
// renderer). Record what the buffer holds AT THE MOMENT it is published.
var textAtPublish: String? = null
var pendingAtPublish = -1
session.bind(
publishEmulator = {
textAtPublish = session.screenTextForTest().trim()
pendingAtPublish = session.pendingChunkCountForTest()
},
) { }
advanceUntilIdle()
// The publish happened strictly AFTER the flush: the replay bytes are already in the buffer and
// the queue is drained. Under the old synchronous publish this read "" on an empty buffer while
// the confined thread was still flushing — the bind-moment single-writer-vs-UI-read race.
assertEquals("HELLO", textAtPublish, "emulator must be published only after the flush completed")
assertEquals(0, pendingAtPublish, "queue must be drained before the emulator is published")
session.close()
advanceUntilIdle()
}
@Test
fun `screen-update sink fires on bind flush`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val session = RemoteTerminalSession(
engineSend = {},
appendDispatcher = dispatcher,
mainDispatcher = dispatcher,
)
var updates = 0
session.feedRemote("hi".toByteArray(Charsets.UTF_8))
advanceUntilIdle()
session.bind { updates++ }
advanceUntilIdle()
assertTrue(updates >= 1, "binding a pending session must post at least one screen update")
session.close()
advanceUntilIdle()
}
}

View File

@@ -0,0 +1,122 @@
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
/**
* S1 GATE (plan §5 S1 / §6.1) — proves the Termux terminal-emulator seam is drivable HEADLESS: a
* [RemoteTerminalSession] instantiates a `TerminalEmulator` with NO subprocess / NO JNI, we append
* canned WS-shaped ANSI bytes, and read the expected cells back out of `TerminalBuffer`. It also proves
* the OUTBOUND seam: emulator-originated bytes (a DSR reply) flow back through `engineSend`, and typed
* input maps to `Input(...)`. If this fails, A16 is BLOCKED (→ §6.8 fallback).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RemoteTerminalSessionSeamTest {
// Canned WS-shaped bytes: ESC[0m (soft reset) · ESC[32m (green) · "hello" · ESC[0m — the shape a
// ring replay begins with. \u001b is the ESC byte.
private val cannedAnsi = "\u001b[0m\u001b[32mhello\u001b[0m"
@Test
fun `canned ANSI bytes render into the TerminalBuffer`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val session = RemoteTerminalSession(
engineSend = {},
appendDispatcher = dispatcher,
mainDispatcher = dispatcher,
)
session.bind {} // bind so output appends live rather than queueing
session.feedRemote(cannedAnsi.toByteArray(Charsets.UTF_8))
advanceUntilIdle()
val text = session.screenTextForTest().trim()
assertTrue(text.contains("hello"), "emulator did not render 'hello'; got: '$text'")
assertEquals(5, session.emulator.cursorCol, "cursor should advance past 'hello'")
session.close()
advanceUntilIdle()
}
@Test
fun `emulator-originated reply (DSR) flows out through engineSend`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val sent = mutableListOf<ClientMessage>()
val session = RemoteTerminalSession(
engineSend = { sent += it },
appendDispatcher = dispatcher,
mainDispatcher = dispatcher,
)
session.bind {}
// ESC[6n = Device Status Report (cursor position). The emulator answers ESC[<row>;<col>R via
// TerminalOutput.write → engineSend(Input) — the DA/DSR path §6.1 calls out.
session.feedRemote("\u001b[6n".toByteArray(Charsets.UTF_8))
advanceUntilIdle()
val reply = sent.filterIsInstance<ClientMessage.Input>().firstOrNull { it.data.contains('R') }
assertTrue(reply != null, "expected a DSR reply Input containing 'R'; sent: $sent")
session.close()
advanceUntilIdle()
}
@Test
fun `a command that throws is survived and later output still renders`() = runTest {
// FIX 2: a malformed / hostile escape byte that makes the emulator throw must NOT kill the
// single confined consumer and permanently freeze all further output. Here the injected onBell
// throws on the first invocation (standing in for the emulator choking on a hostile byte): the
// BEL byte 0x07 makes the emulator call onBell synchronously mid-append on the confined thread.
val dispatcher = StandardTestDispatcher(testScheduler)
var bellCount = 0
val session = RemoteTerminalSession(
engineSend = {},
onBell = {
bellCount++
if (bellCount == 1) throw RuntimeException("hostile-byte boom")
},
appendDispatcher = dispatcher,
mainDispatcher = dispatcher,
)
session.bind {}
// This feed throws while being processed on the confined thread.
session.feedRemote("\u0007".toByteArray(Charsets.UTF_8))
advanceUntilIdle()
// The consumer survived — a subsequent feed still appends and renders.
session.feedRemote("OK".toByteArray(Charsets.UTF_8))
advanceUntilIdle()
assertTrue(
session.screenTextForTest().trim().contains("OK"),
"consumer must survive a throwing command and keep rendering; got: '${session.screenTextForTest().trim()}'",
)
session.close()
advanceUntilIdle()
}
@Test
fun `writeInput maps verbatim to Input`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val sent = mutableListOf<ClientMessage>()
val session = RemoteTerminalSession(
engineSend = { sent += it },
appendDispatcher = dispatcher,
mainDispatcher = dispatcher,
)
session.writeInput("ls -la\r")
assertEquals(listOf<ClientMessage>(ClientMessage.Input("ls -la\r")), sent)
session.close()
advanceUntilIdle()
}
}

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.terminalview
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The R5 gate (plan §6.4) — the pure cols×rows resize arithmetic, verified against KNOWN font metrics
* so it can never drift from the server/web/iOS contract without a device. The real metrics come from
* Termux's `TerminalRenderer` at runtime; this test pins the formula that turns them into a grid.
*/
class TerminalGridMathTest {
// 10px-wide cells, 20px line spacing — an easy exact-division case.
private val fontWidth = 10f
private val lineSpacing = 20
@Test
fun `exact division yields expected grid minus symmetric padding`() {
// usableW = 820 - 2*10 = 800 → 80 cols; usableH = 500 - 2*10 = 480 → 24 rows.
val grid = TerminalGridMath.computeGridSize(
viewWidthPx = 820, viewHeightPx = 500,
horizontalPaddingPx = 10, verticalPaddingPx = 10,
fontWidthPx = fontWidth, fontLineSpacingPx = lineSpacing,
)
assertEquals(TerminalGridSize(cols = 80, rows = 24), grid)
}
@Test
fun `partial cells floor down`() {
// usableW = 795 → floor(795/10)=79 cols; usableH = 495 → floor(495/20)=24 rows.
val grid = TerminalGridMath.computeGridSize(
viewWidthPx = 795, viewHeightPx = 495,
horizontalPaddingPx = 0, verticalPaddingPx = 0,
fontWidthPx = fontWidth, fontLineSpacingPx = lineSpacing,
)
assertEquals(TerminalGridSize(cols = 79, rows = 24), grid)
}
@Test
fun `non-positive view dimensions drop to null (pre-layout)`() {
assertNull(
TerminalGridMath.computeGridSize(0, 500, 0, 0, fontWidth, lineSpacing),
"zero width is pre-layout — no grid",
)
assertNull(
TerminalGridMath.computeGridSize(820, 0, 0, 0, fontWidth, lineSpacing),
"zero height is pre-layout — no grid",
)
}
@Test
fun `non-positive metrics drop to null`() {
assertNull(TerminalGridMath.computeGridSize(820, 500, 0, 0, 0f, lineSpacing))
assertNull(TerminalGridMath.computeGridSize(820, 500, 0, 0, fontWidth, 0))
}
@Test
fun `padding larger than the view drops to null`() {
assertNull(
TerminalGridMath.computeGridSize(20, 500, 20, 0, fontWidth, lineSpacing),
"usable width <= 0 after padding — no grid",
)
}
@Test
fun `a tiny viewport still clamps to at least 1x1`() {
val grid = TerminalGridMath.computeGridSize(5, 5, 0, 0, fontWidth, lineSpacing)
assertEquals(TerminalGridSize(cols = 1, rows = 1), grid)
}
}