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
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user