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