fix(android): make the terminal actually usable on a device
The client built to an APK and 617 JVM tests passed, but nothing had ever
run on hardware, and three defects made it unusable the moment it did. All
three were invisible to the JVM suite because none of those tests
instantiate an Android View.
1. Every key press crashed. `RemoteTerminalView` bound the forked emulator
but never installed a `TerminalViewClient`, and the stock Termux view
dereferences `mClient` with no null guard on the first line of the paths
a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
offset 0, `onKeyUp`, `onKeyPreIme`). Installing any client is not enough:
returning false continues into `mTermSession.write()` at offset 150, and
`mTermSession` is null forever because `TerminalSession` is final and
forking a process is what this app must not do. So the client consumes
the key itself and `KeyRouting` has no defer-to-stock branch at all —
the crash is unrepresentable, not merely avoided. Termux's `KeyHandler`
stays the authority for the key tables, so DECCKM still emits `ESC O A`.
2. The PTY never learned the real size. Nothing called
`RemoteTerminalSession.updateSize`, and the stock fallback is dead
(`TerminalView.updateSize` early-returns without a session), so every
full-screen TUI rendered into 80x24. `TerminalResizeDriver` now drives
it from real cell metrics — read through the public `getFontWidth()` /
`getFontLineSpacing()` accessors rather than reflection, which would
break silently under R8, or a re-measured Paint, which would drift from
what the renderer actually draws (plan risk R5).
3. Bare-LAN connections were impossible. `network_security_config.xml` was
a self-labelled STUB denying all cleartext while `HostEndpoint` accepts
http and derives ws://. The format has no CIDR syntax, so the allowlist
its own comment promised cannot be written; cleartext is permitted in
base-config, the tunnel domain keeps a TLS block, trust anchors are
pinned to system-only, and the guard moves to the §5.4 pairing tiers.
Adversarial review then found three more, and one it got wrong:
- Swipe-scrolling inside an alternate-screen TUI still crashed. The touch
path bypasses `TerminalViewClient` entirely: `doScroll` turns a scroll
into `handleKeyCode` whenever the alternate buffer is active, and that
dereferences `mTermSession`. Claude Code is an alternate-screen TUI, so
this was a crash during normal use on the app's main screen.
`TerminalScrollGesture` claims the vertical drag first and reproduces
all three `doScroll` branches, closing the fling runnable and
`onGenericMotionEvent` with it.
- `autofill()` dereferences `mTermSession` unguarded while the view
advertises itself autofillable, so any password manager crashed it.
The subtree is excluded from the autofill structure.
- The review asked for stock text selection to be restored. It cannot be:
the ActionMode's own Copy and Paste handlers dereference the absent
session, so a reachable selection UI has an NPE on its Copy button.
Long press stays consumed and copy-out is recorded as a feature gap.
Also: the WEBTERM_TOKEN cookie is carried on REST and the WS upgrade and
sealed with Tink AEAD under an AndroidKeyStore key (fail-closed — a seal
failure persists nothing rather than falling back to plaintext), and
`allowBackup=false` keeps a 30-day shell credential off Google Drive.
Verified: ./gradlew test :app:assembleDebug -> 757 JVM tests, 0 failures.
Nothing here is device-verified yet; that is the next step.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.content.Context
|
||||
import android.util.TypedValue
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
import com.termux.view.TerminalView
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The `View` that actually goes into the Compose tree: a frame around the stock Termux [TerminalView],
|
||||
* which keeps doing all the glyph rendering — and which this frame must also keep away from the three
|
||||
* stock paths that dereference the `TerminalSession` this app deliberately does not have.
|
||||
*
|
||||
* ### Why a frame and not the stock view directly (plan §6.1 deviation, recorded)
|
||||
* `com.termux.view.TerminalView` is `public final` — it cannot be subclassed — and its
|
||||
* `onCreateInputConnection` (offsets 52-62) hard-wires the anonymous `TerminalView$2` connection whose
|
||||
* text path dead-ends on the absent `TerminalSession`. `onCreateInputConnection` is called on the
|
||||
* **focused** view and there is no interception hook, so owning the IME contract means owning a focusable
|
||||
* view. This frame is that view: it takes focus, returns [RemoteTerminalInputConnection], and forwards
|
||||
* hardware keys down into the stock view so the [WebTermTerminalViewClient] decision path still runs.
|
||||
*
|
||||
* ### What the frame must intercept, and why (all offsets from the pinned `terminal-view-v0.118.0.aar`)
|
||||
* `mTermSession` is **null forever** here: `TerminalSession` is `final`, its constructor forks a local
|
||||
* process over JNI, and a dummy instance would only move the NPE (`getEmulator()` returns null two
|
||||
* instructions later). Every stock method that dereferences it must therefore be made unreachable:
|
||||
*
|
||||
* 1. **scroll** — `doScroll` offsets 56-79 call `handleKeyCode(19|20, 0)` while the alternate screen
|
||||
* buffer is active, and `handleKeyCode` offsets 15-19 are `mTermSession.getEmulator()`, unguarded.
|
||||
* Reached from `TerminalView$1.onScroll`, the `TerminalView$1$1` fling runnable and
|
||||
* `onGenericMotionEvent`; no `TerminalViewClient` callback sits anywhere on that path.
|
||||
* → [onInterceptTouchEvent] / [dispatchGenericMotionEvent] claim it; [TerminalScrollGesture] does it.
|
||||
* 2. **autofill** — `autofill(AutofillValue)` offsets 7-20 are `mTermSession.write(...)`, and the view
|
||||
* advertises itself as fillable (`getAutofillType()` = AUTOFILL_TYPE_TEXT, `getAutofillValue()`
|
||||
* non-null), so any password manager filling it crashes the app. → the whole subtree is excluded from
|
||||
* the autofill structure ([getImportantForAutofill]).
|
||||
* 3. **keys** — `onKeyDown` offset 150 is `mTermSession.write(getCharacters())`.
|
||||
* → [WebTermTerminalViewClient] consumes every non-system key before that, and answers false to
|
||||
* `shouldBackButtonBeMappedToEscape` so BACK cannot enter the terminal branch either.
|
||||
* 4. **text selection** — `startTextSelectionMode` arms an `ActionMode` whose Copy
|
||||
* (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100) is
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and whose Paste (offsets 126-136) is
|
||||
* `mTermSession.onPasteTextFromClipboard()`. → [WebTermTerminalViewClient.onLongPress] returns true,
|
||||
* which is a **recorded deviation from plan §6.5** (see that method for the full rationale).
|
||||
*
|
||||
* ### Stock-method audit (verified with `javap -p -c`; keep it current when the pinned version moves)
|
||||
* | stock member | reachable here? | verdict |
|
||||
* |---|---|---|
|
||||
* | `onKeyDown` / `onKeyUp` / `onKeyPreIme` | yes | safe — a client is installed and consumes first |
|
||||
* | `onCreateInputConnection` | no (frame owns focus) | client installed anyway |
|
||||
* | `onCheckIsTextEditor`, `isOpaque`, `setTextSize`, `setTypeface` | yes | no session deref |
|
||||
* | `onDraw`, `onScreenUpdated`, `computeVerticalScroll*` | yes | `mEmulator`/`mRenderer` guarded or built in [init] |
|
||||
* | `getColumnAndRow`, `getCursorX/Y`, `getPointX/Y`, `getTopRow`, `setTopRow` | yes | no session deref |
|
||||
* | `onTouchEvent` (mouse buttons: context menu, middle-click paste) | yes | `mEmulator`-guarded, no session deref |
|
||||
* | `doScroll`, `onGenericMotionEvent`, `TerminalView$1.onScroll`, `TerminalView$1$1.run` | **no** | intercepted here (1) |
|
||||
* | `handleKeyCode` | **no** | only `doScroll` called it |
|
||||
* | `autofill`, `getAutofillType`, `getAutofillValue` | **no** | subtree excluded (2) |
|
||||
* | `inputCodePoint` | no | its own `mTermSession == null` early return (offsets 59-66) |
|
||||
* | `updateSize` | no | early-returns without a session; [TerminalResizeDriver] replaces it |
|
||||
* | `attachSession` | no | never called — it is the process-forking path we replace |
|
||||
* | `startTextSelectionMode` / `TextSelectionCursorController` | **no** | long press consumed (4) |
|
||||
* | `setTerminalCursorBlinker*` | never invoked | cosmetic gap vs stock: the cursor does not blink |
|
||||
*
|
||||
* ### The latent renderer crash this also fixes
|
||||
* `TerminalView.mRenderer` is only created by `setTextSize`, and nothing called it. `onDraw` dereferences
|
||||
* `mRenderer` unconditionally once `mEmulator` is non-null (offsets 36-61), so the very first frame after
|
||||
* binding our emulator would have thrown. [init] sets the text size, which both creates the renderer and
|
||||
* is what makes the real cell metrics ([cellMetrics]) available for the resize path.
|
||||
*
|
||||
* DEVICE QA (plan §7): focus/IME behaviour across IMEs, CJK composition, link taps, scroll feel (slop,
|
||||
* momentum) and the real font-metric grid are verified on a device — this class is glue, and the logic it
|
||||
* delegates to ([TerminalKeyDecoder], [TerminalInputBytes], [TerminalResizeDriver], [TerminalScrollGesture])
|
||||
* is what the JVM tests pin.
|
||||
*/
|
||||
public class RemoteTerminalHostView internal constructor(context: Context) : FrameLayout(context) {
|
||||
|
||||
/** The stock Termux view. Rendering is entirely its own; we only feed it an emulator and input. */
|
||||
internal val stockView: TerminalView = TerminalView(context, null)
|
||||
|
||||
/** Set once by [RemoteTerminalView]; every input path routes through it. */
|
||||
internal var sink: TerminalInputSink? = null
|
||||
|
||||
/** The event currently being dispatched — the mouse-report branch needs its coordinates + down-time. */
|
||||
private var eventInFlight: MotionEvent? = null
|
||||
|
||||
/** Stock `mMouseScrollStartX/Y` + `mMouseStartDownTime`: one anchor cell per wheel gesture. */
|
||||
private var wheelAnchorDownTime: Long = NO_DOWN_TIME
|
||||
private var wheelAnchorColumn: Int = 0
|
||||
private var wheelAnchorRow: Int = 0
|
||||
|
||||
private val scrollGesture = TerminalScrollGesture(
|
||||
touchSlopPx = ViewConfiguration.get(context)?.scaledTouchSlop?.takeIf { it > 0 }
|
||||
?: FALLBACK_TOUCH_SLOP_PX,
|
||||
cellHeightPx = { cellMetrics()?.lineSpacingPx ?: NO_CELL_HEIGHT },
|
||||
target = object : TerminalScrollTarget {
|
||||
override fun emulator(): TerminalEmulator? = stockView.mEmulator
|
||||
|
||||
override fun scrollTranscript(up: Boolean) {
|
||||
val transcriptRows = stockView.mEmulator?.screen?.activeTranscriptRows ?: return
|
||||
val step = if (up) -1 else 1
|
||||
stockView.topRow = min(NEWEST_ROW, max(-transcriptRows, stockView.topRow + step))
|
||||
stockView.invalidate()
|
||||
}
|
||||
|
||||
override fun sendKeys(data: String) {
|
||||
sink?.sendInput(data)
|
||||
}
|
||||
|
||||
override fun reportMouseWheel(up: Boolean) = sendMouseWheel(up)
|
||||
},
|
||||
)
|
||||
|
||||
init {
|
||||
addView(stockView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
||||
// Builds TerminalRenderer → onDraw is safe and the cell metrics become readable.
|
||||
stockView.setTextSize(defaultTextSizePx(context))
|
||||
// The frame owns focus and therefore the IME; the stock view must not compete for it.
|
||||
stockView.isFocusable = false
|
||||
stockView.isFocusableInTouchMode = false
|
||||
descendantFocusability = FOCUS_BLOCK_DESCENDANTS
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
// Belt and braces with getImportantForAutofill(): the setter is what a structure walk normally
|
||||
// reads, the override is what a later setImportantForAutofill() cannot undo.
|
||||
importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||
stockView.importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||
}
|
||||
|
||||
/** Install the client on the stock view. Idempotent; called as soon as the sink is known. */
|
||||
internal fun installClient(client: com.termux.view.TerminalViewClient) {
|
||||
stockView.setTerminalViewClient(client)
|
||||
}
|
||||
|
||||
/** Point the stock renderer at our forked emulator (bypasses the process-forking `attachSession`). */
|
||||
internal fun bindEmulator(emulator: TerminalEmulator) {
|
||||
stockView.mEmulator = emulator
|
||||
// A new emulator means a new transcript: drop any half-finished drag rather than let it resolve
|
||||
// rows against a buffer it did not start in.
|
||||
scrollGesture.reset()
|
||||
}
|
||||
|
||||
/** Invalidate/redraw the stock view for the current emulator state (main thread). */
|
||||
internal fun requestScreenUpdate() {
|
||||
stockView.onScreenUpdated()
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer's real cell metrics, or null before the renderer exists. Read through
|
||||
* `TerminalRenderer`'s **public** `getFontWidth()`/`getFontLineSpacing()` accessors — see
|
||||
* [TerminalCellMetrics] for why that beats reflection or a re-measured `Paint`.
|
||||
*/
|
||||
internal fun cellMetrics(): TerminalCellMetrics? {
|
||||
val renderer = stockView.mRenderer ?: return null
|
||||
return TerminalCellMetrics(renderer.fontWidth, renderer.fontLineSpacing)
|
||||
}
|
||||
|
||||
/** The stock view's padding, which the grid maths subtracts (plan §6.4). */
|
||||
internal fun horizontalPadding(): Int = stockView.paddingLeft
|
||||
internal fun verticalPadding(): Int = stockView.paddingTop
|
||||
|
||||
/** Take focus and raise the soft keyboard — the response to a tap on the terminal. */
|
||||
internal fun showSoftKeyboard() {
|
||||
requestFocus()
|
||||
val inputMethods = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
inputMethods?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
|
||||
// ── Autofill ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Keep the whole terminal subtree out of the autofill structure.
|
||||
*
|
||||
* `TerminalView.autofill(AutofillValue)` writes the filled text straight into a null `mTermSession`
|
||||
* (offsets 7-20), and the view declares itself fillable, so a password manager offering to fill it is
|
||||
* a crash. `View.isImportantForAutofill()` walks the parent chain calling this getter and stops at the
|
||||
* first `*_EXCLUDE_DESCENDANTS`, so answering here covers the stock child as well. Overriding rather
|
||||
* than only setting the flag also means no later `setImportantForAutofill` can quietly re-enable it.
|
||||
*
|
||||
* Nothing is lost: a terminal has no fillable fields, and shell input is exactly the content that
|
||||
* should never enter an autofill structure (plan §8 — no secret leakage).
|
||||
*/
|
||||
override fun getImportantForAutofill(): Int = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||
|
||||
// ── IME ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCheckIsTextEditor(): Boolean = true
|
||||
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
||||
val sink = this.sink ?: return null
|
||||
// VISIBLE_PASSWORD + NO_SUGGESTIONS: no autocorrect, no predictive text and no learned-word
|
||||
// history of what was typed into a shell. NO_EXTRACT_UI/NO_FULLSCREEN keep the landscape IME
|
||||
// from covering the terminal with a full-screen text box.
|
||||
outAttrs.inputType = EditorInfo.TYPE_CLASS_TEXT or
|
||||
EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
|
||||
EditorInfo.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
||||
EditorInfo.IME_FLAG_NO_FULLSCREEN or
|
||||
EditorInfo.IME_ACTION_NONE
|
||||
return RemoteTerminalInputConnection(view = this, sink = sink, cursorModes = { sink.cursorModes() })
|
||||
}
|
||||
|
||||
// ── Keys ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// The frame holds focus, so key events land here first. Terminal keys are forwarded into the stock
|
||||
// view so the WebTermTerminalViewClient decision path runs (that is where DECCKM and the byte
|
||||
// encoding live); system keys skip it entirely.
|
||||
//
|
||||
// The skip is not just a shortcut: `TerminalView.onKeyDown` returns TRUE unconditionally while
|
||||
// `mEmulator` is still null (offsets 62-70). Routing BACK through it would silently swallow the key
|
||||
// during the window before the emulator binds — and forever if a session never binds — leaving the
|
||||
// user stuck on the terminal screen. Answering system keys from here keeps that exit always open.
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean =
|
||||
if (event.isSystem) super.onKeyDown(keyCode, event)
|
||||
else stockView.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean =
|
||||
if (event.isSystem) super.onKeyUp(keyCode, event)
|
||||
else stockView.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
|
||||
|
||||
/** Lets the stock view close an active text selection with BACK before the IME/Activity sees it. */
|
||||
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean =
|
||||
stockView.onKeyPreIme(keyCode, event) || super.onKeyPreIme(keyCode, event)
|
||||
|
||||
// ── Touch & layout ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Claim focus on touch-down without consuming the gesture — taps, long presses and the mouse-button
|
||||
* paths still reach the stock view.
|
||||
*/
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && !isFocused) requestFocus()
|
||||
return super.dispatchTouchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take vertical drags away from the stock view.
|
||||
*
|
||||
* Answering true makes `ViewGroup` send the stock view `ACTION_CANCEL` and route the remainder of the
|
||||
* gesture to [onTouchEvent] here — so `mGestureRecognizer` never observes a scroll, cannot call
|
||||
* `TerminalView$1.onScroll` and cannot start the `TerminalView$1$1` fling, which is what makes the
|
||||
* crashing `doScroll` unreachable rather than merely unlikely. Taps and pinches are never claimed.
|
||||
*/
|
||||
override fun onInterceptTouchEvent(event: MotionEvent): Boolean = routeTouch(event)
|
||||
|
||||
/** The rest of a claimed drag. Returning true keeps the gesture here until it ends. */
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean =
|
||||
routeTouch(event) || scrollGesture.isOwned || super.onTouchEvent(event)
|
||||
|
||||
/**
|
||||
* The external-mouse wheel, the second stock entry into `doScroll` (offsets 43-55). `ViewGroup`
|
||||
* offers generic pointer events to children first, so this override is the only place ahead of it.
|
||||
*/
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE) && event.action == MotionEvent.ACTION_SCROLL) {
|
||||
eventInFlight = event
|
||||
try {
|
||||
scrollGesture.onMouseWheel(scrollUp = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0f)
|
||||
} finally {
|
||||
eventInFlight = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
||||
super.onSizeChanged(width, height, oldWidth, oldHeight)
|
||||
sink?.onViewSize(width, height)
|
||||
}
|
||||
|
||||
private fun routeTouch(event: MotionEvent): Boolean {
|
||||
eventInFlight = event
|
||||
try {
|
||||
return scrollGesture.onTouch(TouchFacts.of(event))
|
||||
} finally {
|
||||
eventInFlight = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock `sendMouseEventCode(event, MOUSE_WHEEL{UP,DOWN}_BUTTON, true)`, offsets 0-100: report the
|
||||
* wheel at the cell the gesture started on (one anchor per down-time) so a program tracking the mouse
|
||||
* sees a stable position rather than one that drifts with the finger.
|
||||
*/
|
||||
private fun sendMouseWheel(up: Boolean) {
|
||||
val emulator = stockView.mEmulator ?: return
|
||||
val event = eventInFlight ?: return
|
||||
val cell = stockView.getColumnAndRow(event, false)
|
||||
var column = cell[COLUMN_INDEX] + CELL_ORIGIN
|
||||
var row = cell[ROW_INDEX] + CELL_ORIGIN
|
||||
if (wheelAnchorDownTime == event.downTime) {
|
||||
column = wheelAnchorColumn
|
||||
row = wheelAnchorRow
|
||||
} else {
|
||||
wheelAnchorDownTime = event.downTime
|
||||
wheelAnchorColumn = column
|
||||
wheelAnchorRow = row
|
||||
}
|
||||
val button =
|
||||
if (up) TerminalEmulator.MOUSE_WHEELUP_BUTTON else TerminalEmulator.MOUSE_WHEELDOWN_BUTTON
|
||||
emulator.sendMouseEvent(button, column, row, true)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Terminal body text, in sp so it follows the system font scale. */
|
||||
const val TEXT_SIZE_SP: Float = 12f
|
||||
|
||||
/** Guard rails so an extreme font scale cannot produce an unusable (or zero-width) cell. */
|
||||
const val MIN_TEXT_SIZE_PX: Int = 8
|
||||
const val MAX_TEXT_SIZE_PX: Int = 64
|
||||
|
||||
/**
|
||||
* Used when `ViewConfiguration` cannot be resolved (or reports 0, which would make every touch a
|
||||
* drag). The platform's own default `scaledTouchSlop` at mdpi, so behaviour degrades sensibly
|
||||
* rather than turning taps into scrolls.
|
||||
*/
|
||||
const val FALLBACK_TOUCH_SLOP_PX: Int = 8
|
||||
|
||||
/** The renderer has not measured a cell yet. */
|
||||
const val NO_CELL_HEIGHT: Int = 0
|
||||
|
||||
/** Stock `mTopRow == 0` means "showing the live screen"; negative walks back into scrollback. */
|
||||
const val NEWEST_ROW: Int = 0
|
||||
|
||||
/** No wheel gesture has been anchored yet (stock initialises `mMouseStartDownTime` to -1). */
|
||||
const val NO_DOWN_TIME: Long = -1L
|
||||
|
||||
/** `getColumnAndRow` returns `[column, row]`, both 0-based; the wire protocol is 1-based. */
|
||||
const val COLUMN_INDEX: Int = 0
|
||||
const val ROW_INDEX: Int = 1
|
||||
const val CELL_ORIGIN: Int = 1
|
||||
|
||||
fun defaultTextSizePx(context: Context): Int = TypedValue
|
||||
.applyDimension(TypedValue.COMPLEX_UNIT_SP, TEXT_SIZE_SP, context.resources.displayMetrics)
|
||||
.roundToInt()
|
||||
.coerceIn(MIN_TEXT_SIZE_PX, MAX_TEXT_SIZE_PX)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
|
||||
/**
|
||||
* BLOCKER 1, the soft-keyboard half — our own `InputConnection`, because the stock one cannot work
|
||||
* without a `TerminalSession`.
|
||||
*
|
||||
* Traced in the pinned artifact (`TerminalView$2`, the anonymous `BaseInputConnection` returned by
|
||||
* `TerminalView.onCreateInputConnection`):
|
||||
* `commitText` → `sendTextToTerminal` → `TerminalView.inputCodePoint(...)`, and `inputCodePoint`
|
||||
* **returns immediately at offset 59–66 when `mTermSession == null`**. So with our forked session the
|
||||
* stock connection is not merely crash-prone, it is a black hole: everything typed on the soft keyboard
|
||||
* would vanish with no error. (`deleteSurroundingText` is worse — it round-trips through
|
||||
* `sendKeyEvent`, whose stock landing zone is the same dead path.)
|
||||
*
|
||||
* This replacement keeps the parts of `BaseInputConnection` that matter for composition (CJK/emoji IMEs
|
||||
* build text in the editable before committing) and redirects the delivery to [TerminalInputSink], i.e.
|
||||
* `RemoteTerminalSession.writeInput` → `engineSend(Input(..))`. `mTermSession` is not referenced at all.
|
||||
*
|
||||
* @param view the host view — `BaseInputConnection` needs it for the editable and for key dispatch.
|
||||
*/
|
||||
internal class RemoteTerminalInputConnection(
|
||||
view: View,
|
||||
private val sink: TerminalInputSink,
|
||||
private val cursorModes: () -> TerminalCursorModes?,
|
||||
) : BaseInputConnection(view, /* fullEditor = */ true) {
|
||||
|
||||
/**
|
||||
* The IME committed text (the normal typing path, and the end of a CJK composition). Mirrors
|
||||
* Termux's order: let the base class fold the commit into the editable so composing state resolves
|
||||
* correctly, then drain the editable to the wire and clear it — the terminal, not the editable, is
|
||||
* the document.
|
||||
*/
|
||||
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||
super.commitText(text, newCursorPosition)
|
||||
drainToTerminal(fallback = text)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Composition ended (IME switch, focus loss): flush whatever was staged rather than dropping it. */
|
||||
override fun finishComposingText(): Boolean {
|
||||
super.finishComposingText()
|
||||
drainToTerminal(fallback = null)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Backspace from the IME. Resolved through Termux's [com.termux.terminal.KeyHandler] (via the same
|
||||
* decoder as a hardware key) so the byte matches what a physical Backspace sends — `DEL`, or `ESC
|
||||
* DEL` under Alt — instead of a hand-rolled guess.
|
||||
*/
|
||||
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
||||
repeat(beforeLength.coerceAtLeast(0)) { sendKeyCode(KeyEvent.KEYCODE_DEL) }
|
||||
return super.deleteSurroundingText(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* Some IMEs deliver Enter/Backspace/arrows as key events rather than text. Handled here rather than
|
||||
* dispatched into the view, so this path can never re-enter the stock terminal handling.
|
||||
*/
|
||||
override fun sendKeyEvent(event: KeyEvent?): Boolean {
|
||||
if (event == null) return false
|
||||
if (event.action != KeyEvent.ACTION_DOWN) return true
|
||||
sendKeyCode(event.keyCode, KeyEventFacts.of(event))
|
||||
return true
|
||||
}
|
||||
|
||||
private fun sendKeyCode(keyCode: Int, facts: KeyEventFacts = PLAIN_KEY) {
|
||||
val modes = cursorModes()
|
||||
val routing = TerminalKeyDecoder.decodeKeyDown(
|
||||
keyCode = keyCode,
|
||||
facts = facts,
|
||||
appConsumed = false,
|
||||
cursorKeysApplication = modes?.cursorKeysApplication ?: false,
|
||||
keypadApplication = modes?.keypadApplication ?: false,
|
||||
)
|
||||
if (routing is KeyRouting.Send) sink.sendInput(routing.bytes)
|
||||
}
|
||||
|
||||
private fun drainToTerminal(fallback: CharSequence?) {
|
||||
val staged = editable
|
||||
val content: CharSequence = if (staged != null && staged.isNotEmpty()) staged else fallback ?: return
|
||||
val bytes = TerminalInputBytes.encodeText(content)
|
||||
if (bytes.isNotEmpty()) sink.sendInput(bytes)
|
||||
staged?.clear()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** An unmodified, non-system key with no keymap character — the shape of an IME-synthesised key. */
|
||||
val PLAIN_KEY = KeyEventFacts(
|
||||
isSystemKey = false,
|
||||
ctrlPressed = false,
|
||||
altPressed = false,
|
||||
shiftPressed = false,
|
||||
numLockOn = false,
|
||||
functionPressed = false,
|
||||
metaState = 0,
|
||||
unicodeChar = { 0 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -125,13 +125,20 @@ public class RemoteTerminalSession(
|
||||
|
||||
/**
|
||||
* Latest-writer-wins resize (§6.4). Resolves the emulator's local buffer reflow AND emits a
|
||||
* `Resize` to the server (→ SIGWINCH) — but only when [dims] actually change from [lastSentDims]
|
||||
* and are positive. The dedup + `emulator.resize` are serialized on the confined thread so a resize
|
||||
* never races an append.
|
||||
* `Resize` to the server (→ SIGWINCH) — normally only when the dims actually change from
|
||||
* [lastSentDims] and are positive. The dedup + `emulator.resize` are serialized on the confined
|
||||
* thread so a resize never races an append.
|
||||
*
|
||||
* @param force emit even when the dims are unchanged. This is what makes the §6.4 reclaim work: a
|
||||
* shared PTY has ONE size, so a device coming back to the foreground has to re-assert dims it has
|
||||
* already sent in order to take that size back from whichever device wrote last. Without this the
|
||||
* re-assert is silently swallowed here and the returning device stays letterboxed. Reserved for
|
||||
* that deliberate reclaim — ordinary layout churn must NOT set it, or every inset change becomes a
|
||||
* wire frame and a SIGWINCH.
|
||||
*/
|
||||
public fun updateSize(cols: Int, rows: Int) {
|
||||
public fun updateSize(cols: Int, rows: Int, force: Boolean = false) {
|
||||
if (cols < TerminalGridMath.MIN_DIMENSION || rows < TerminalGridMath.MIN_DIMENSION) return
|
||||
commands.trySend(TerminalCommand.Resize(cols, rows))
|
||||
commands.trySend(TerminalCommand.Resize(cols, rows, force))
|
||||
}
|
||||
|
||||
// ── Outbound: typed / key-bar bytes → wire ───────────────────────────────────────────────────────
|
||||
@@ -198,7 +205,7 @@ public class RemoteTerminalSession(
|
||||
try {
|
||||
when (command) {
|
||||
is TerminalCommand.Feed -> onFeed(command.bytes)
|
||||
is TerminalCommand.Resize -> onResize(command.cols, command.rows)
|
||||
is TerminalCommand.Resize -> onResize(command.cols, command.rows, command.force)
|
||||
is TerminalCommand.Bind -> onBind(command.publishEmulator, command.onScreenUpdated)
|
||||
TerminalCommand.Unbind -> {
|
||||
bound = false
|
||||
@@ -242,12 +249,17 @@ public class RemoteTerminalSession(
|
||||
}
|
||||
}
|
||||
|
||||
private fun onResize(cols: Int, rows: Int) {
|
||||
private suspend fun onResize(cols: Int, rows: Int, force: Boolean) {
|
||||
val next = TerminalGridSize(cols, rows)
|
||||
if (next == lastSentDims) return
|
||||
// `force` is the §6.4 reclaim: unchanged dims must still reach the wire to win the shared PTY
|
||||
// size back. Everything else is deduped here as the last line of defence against layout churn.
|
||||
if (next == lastSentDims && !force) return
|
||||
emulator.resize(cols, rows) // local buffer reflow (confined-thread single writer)
|
||||
engineSend(ClientMessage.Resize(cols, rows)) // → server SIGWINCH
|
||||
lastSentDims = next
|
||||
// The reflow rewrote the on-screen cells; without a repaint the user keeps looking at the old
|
||||
// wrap points until the next byte arrives (which, on an idle Claude session, can be minutes).
|
||||
postScreenUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,7 +314,7 @@ public class RemoteTerminalSession(
|
||||
/** Ordered commands to the single confined consumer — FIFO submission order == emulator write order. */
|
||||
private sealed interface TerminalCommand {
|
||||
class Feed(val bytes: ByteArray) : TerminalCommand
|
||||
class Resize(val cols: Int, val rows: Int) : TerminalCommand
|
||||
class Resize(val cols: Int, val rows: Int, val force: Boolean = false) : TerminalCommand
|
||||
class Bind(val publishEmulator: () -> Unit, val onScreenUpdated: () -> Unit) : TerminalCommand
|
||||
data object Unbind : TerminalCommand
|
||||
}
|
||||
|
||||
@@ -3,54 +3,130 @@ package wang.yaojia.webterm.terminalview
|
||||
import android.content.Context
|
||||
import android.view.KeyEvent
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
import com.termux.view.TerminalView
|
||||
|
||||
/**
|
||||
* The on-screen terminal seam — a COMPOSITION wrapper around Termux's stock [TerminalView].
|
||||
* The on-screen terminal seam — a COMPOSITION wrapper around Termux's stock `TerminalView`.
|
||||
*
|
||||
* ### Deviation from plan §6.1 (recorded): composition, not subclass
|
||||
* §6.1 says "the Termux `TerminalView` is subclassed only to expose an onKeyCommand outlet + install
|
||||
* the key-bar." But at the pinned v0.118.0, `com.termux.view.TerminalView` is declared
|
||||
* `public final class` — it CANNOT be subclassed (verified: the Kotlin compiler rejects
|
||||
* `: TerminalView(...)` with "This type is final"). We therefore WRAP a stock instance and bind our
|
||||
* forked emulator through its PUBLIC `mEmulator` field instead of via `attachSession(TerminalSession)`
|
||||
* (there is no `TerminalSession` to fork — it too is `final`, and forking a process is exactly what we
|
||||
* must not do). Glyph rendering, cursor, selection, IME and scroll stay 100% stock — the wrapped view
|
||||
* renders unchanged; we only redirect where the emulator comes from and where input goes.
|
||||
* the key-bar." At the pinned v0.118.0 `com.termux.view.TerminalView` is declared `public final class`,
|
||||
* so it CANNOT be subclassed. We therefore wrap a stock instance inside a [RemoteTerminalHostView] and
|
||||
* bind our forked emulator through its public `mEmulator` field instead of via
|
||||
* `attachSession(TerminalSession)` (there is no `TerminalSession` to fork — it too is `final`, and
|
||||
* forking a process is exactly what we must not do).
|
||||
*
|
||||
* The [terminalView] is what A21 puts in the Compose tree (`AndroidView { remote.terminalView }`); the
|
||||
* key-bar / hardware-chord layer (A17) installs its handler through [onKeyCommand] (delivered via a
|
||||
* `TerminalViewClient` A17 sets on [terminalView]) and reads [onViewSizeChanged] for the resize path.
|
||||
* **Glyph rendering and the cursor stay 100% stock. Scrolling and text selection do not** — both stock
|
||||
* implementations dereference the absent `TerminalSession`, so [RemoteTerminalHostView] owns vertical
|
||||
* drags and the long press is consumed ([WebTermTerminalViewClient.onLongPress], a recorded §6.5
|
||||
* deviation). [RemoteTerminalHostView]'s KDoc holds the enumerated audit of every stock member: which are
|
||||
* reachable, which are proven safe, and which had to be intercepted.
|
||||
*
|
||||
* DEVICE-QA (plan §7): rendering, IME, selection, link taps, and the real font-metric→grid resize
|
||||
* (`TerminalRenderer.mFontWidth` is package-private → measured on-device and fed to [TerminalGridMath])
|
||||
* are verified on a device, not here. This class only has to compile and offer the binding seam.
|
||||
* ### What this class owns
|
||||
* It is the single place that knows which [RemoteTerminalSession] is bound, so it implements the
|
||||
* [TerminalInputSink] every android-bound helper funnels into:
|
||||
* - **keys** — [RemoteTerminalHostView] → stock `TerminalView` → [WebTermTerminalViewClient] →
|
||||
* [TerminalKeyDecoder] → `writeInput`. Installing that client is what stops the guaranteed NPE on the
|
||||
* first key press (`mClient` is dereferenced unguarded at `TerminalView.onKeyDown` offset 82–92).
|
||||
* - **soft keyboard** — [RemoteTerminalHostView.onCreateInputConnection] →
|
||||
* [RemoteTerminalInputConnection] → `writeInput`, replacing a stock connection that silently drops
|
||||
* everything without a `TerminalSession`.
|
||||
* - **resize** — [RemoteTerminalHostView.onSizeChanged] → [TerminalResizeDriver] →
|
||||
* [RemoteTerminalSession.updateSize], which is the ONLY reason the server PTY ever learns the real
|
||||
* grid: `TerminalView.updateSize()` early-returns without a session (offsets 18–25).
|
||||
*
|
||||
* DEVICE-QA (plan §7): rendering, IME/CJK composition, selection, link taps, focus behaviour and grid
|
||||
* parity against web/iOS on the same physical size are verified on a device (risk R5).
|
||||
*/
|
||||
public class RemoteTerminalView(context: Context) {
|
||||
|
||||
/** The stock, final Termux view. Added to the Compose tree by A21; rendering is entirely its own. */
|
||||
public val terminalView: TerminalView = TerminalView(context, null)
|
||||
private val sink = object : TerminalInputSink {
|
||||
override fun appConsumesKey(keyCode: Int, event: KeyEvent): Boolean =
|
||||
onKeyCommand?.invoke(keyCode, event) == true
|
||||
|
||||
override fun cursorModes(): TerminalCursorModes? = session?.let {
|
||||
TerminalCursorModes(
|
||||
cursorKeysApplication = it.emulator.isCursorKeysApplicationMode,
|
||||
keypadApplication = it.emulator.isKeypadApplicationMode,
|
||||
)
|
||||
}
|
||||
|
||||
override fun sendInput(data: String) {
|
||||
session?.writeInput(data)
|
||||
}
|
||||
|
||||
override fun onTapped() {
|
||||
terminalView.showSoftKeyboard()
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) {
|
||||
resizeDriver.onViewSize(
|
||||
widthPx = widthPx,
|
||||
heightPx = heightPx,
|
||||
horizontalPaddingPx = terminalView.horizontalPadding(),
|
||||
verticalPaddingPx = terminalView.verticalPadding(),
|
||||
)
|
||||
// Keep the §6.4 latest-writer-wins outlet firing AFTER the grid is pushed, so :app's
|
||||
// reclaim (`notifyForegrounded`) acts on dims the server has already been told about.
|
||||
onViewSizeChanged?.invoke(widthPx, heightPx)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `View` A21 puts in the Compose tree (`AndroidView { remote.terminalView }`). It is the frame
|
||||
* described in [RemoteTerminalHostView], not the raw Termux view — owning focus is the only way to
|
||||
* own the `InputConnection`, since the stock `TerminalView` is final.
|
||||
*/
|
||||
public val terminalView: RemoteTerminalHostView = RemoteTerminalHostView(context)
|
||||
|
||||
// Declared before `session`, whose setter calls into it.
|
||||
private val resizeDriver = TerminalResizeDriver(
|
||||
cellMetrics = { terminalView.cellMetrics() },
|
||||
applyGrid = { grid, isReassert ->
|
||||
session?.updateSize(grid.cols, grid.rows, force = isReassert)
|
||||
},
|
||||
)
|
||||
|
||||
/** The forked session backing this view. Set by [RemoteTerminalSession.attachView]. */
|
||||
public var session: RemoteTerminalSession? = null
|
||||
set(value) {
|
||||
field = value
|
||||
// A new session means a new emulator at its default grid, so the grid this view already
|
||||
// measured has to be pushed again even though the pixels never changed — a forced re-assert
|
||||
// rather than a memo reset, so it survives the writer's own dedup too (§6.4).
|
||||
resizeDriver.reassertLastGrid()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardware-key outlet (A17 install point). A17 wires this into a `TerminalViewClient` set on
|
||||
* [terminalView]; returning true consumes the event, otherwise arrows/Enter/Tab should be resolved
|
||||
* via [RemoteTerminalSession.keyBytes] so DECCKM still emits `ESC O A` (plan §6.3).
|
||||
* Hardware-key outlet (A17 install point). Returning true means `:app` handled AND already sent the
|
||||
* key; returning false leaves it to Termux's `KeyHandler` so DECCKM still emits `ESC O A`
|
||||
* (plan §6.3) — that fallback now runs inside [TerminalKeyDecoder] rather than inside the stock
|
||||
* Termux path, which would have dereferenced the absent `TerminalSession`.
|
||||
*/
|
||||
public var onKeyCommand: ((keyCode: Int, event: KeyEvent) -> Boolean)? = null
|
||||
|
||||
/** Layout-change outlet (A17/A21 install point) — wires real font metrics into the resize path. */
|
||||
/** Layout-change outlet (A17/A21 install point) — the §6.4 device-switch reclaim hook. */
|
||||
public var onViewSizeChanged: ((widthPx: Int, heightPx: Int) -> Unit)? = null
|
||||
|
||||
/** Point the stock renderer at the forked emulator (bypasses the process-forking `attachSession`). */
|
||||
init {
|
||||
terminalView.sink = sink
|
||||
// Installed BEFORE any emulator binds: touch, focus and the IME can all arrive first, and every
|
||||
// one of those stock paths dereferences `mClient` without a null check.
|
||||
terminalView.installClient(WebTermTerminalViewClient(sink))
|
||||
}
|
||||
|
||||
/** Point the stock renderer at the forked emulator, then re-assert the measured grid on it. */
|
||||
public fun bindEmulator(emulator: TerminalEmulator) {
|
||||
terminalView.mEmulator = emulator
|
||||
terminalView.bindEmulator(emulator)
|
||||
// The emulator starts at the server's 80x24 default; the view may already know better. Both calls
|
||||
// are needed and they do not overlap: the re-assert covers "already measured" (and pushes from the
|
||||
// memo, so it does not wait for a layout pass that may never come), while onViewSize covers the
|
||||
// first bind, when there is nothing memoised yet. Each is a no-op in the other's case.
|
||||
resizeDriver.reassertLastGrid()
|
||||
sink.onViewSize(terminalView.width, terminalView.height)
|
||||
}
|
||||
|
||||
/** Invalidate/redraw the stock view for the current emulator state (posted on the main thread). */
|
||||
public fun requestScreenUpdate() {
|
||||
terminalView.onScreenUpdated()
|
||||
terminalView.requestScreenUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyCharacterMap
|
||||
import android.view.KeyEvent
|
||||
import com.termux.terminal.KeyHandler
|
||||
|
||||
/**
|
||||
* The pure byte arithmetic our forked session has to own, because the two stock Termux methods that
|
||||
* normally do it both dead-end on the `TerminalSession` we do not have (plan §6.1 — no local process).
|
||||
*
|
||||
* Re-derived from the pinned artifact `terminal-view-v0.118.0.aar` / `terminal-emulator-v0.118.0.aar`:
|
||||
* - `TerminalView.inputCodePoint(codePoint, ctrlDown, altDown)` — the Ctrl transform (offsets 148–318)
|
||||
* and the dead-key aliases (offsets 319–374). It **early-returns at offset 59–66 when
|
||||
* `mTermSession == null`**, which is permanently our case, so the whole path is dead for us.
|
||||
* - `TerminalSession.writeCodePoint(prependEscape, codePoint)` — the `ESC` prefix for Alt and the UTF-8
|
||||
* encode (offsets 45–272). It **throws `IllegalArgumentException`** for a surrogate/out-of-range code
|
||||
* point (offsets 0–44); we return `null` instead, because a garbled key event must never take down
|
||||
* the key path.
|
||||
*
|
||||
* Everything here is pure Kotlin over ints and strings — no view, no session, no android call at
|
||||
* runtime (the `KeyEvent`/`KeyCharacterMap` constants are Java compile-time constants and inline).
|
||||
* Bytes leave as a `String` because that is the wire shape (`ClientMessage.Input`) and the transport
|
||||
* encodes UTF-8 once, at the boundary.
|
||||
*/
|
||||
internal object TerminalInputBytes {
|
||||
|
||||
/** `ESC` (0x1B) — the Alt prefix and the lead byte of every CSI/SS3 sequence. */
|
||||
const val ESC: String = "\u001b"
|
||||
|
||||
/** `KeyCharacterMap.COMBINING_ACCENT` — set by `getUnicodeChar` when the key is a dead key. */
|
||||
val COMBINING_ACCENT: Int = KeyCharacterMap.COMBINING_ACCENT
|
||||
|
||||
/** All Ctrl bits — stripped before asking the keymap for a printable char (Termux offset 357). */
|
||||
val META_CTRL_MASK: Int = KeyEvent.META_CTRL_MASK
|
||||
|
||||
/** The Alt bits Termux also strips unless NumLock is on (offsets 362–375: `18`). */
|
||||
val META_ALT_BITS: Int = KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON
|
||||
|
||||
/** The Shift bits Termux re-adds when shift is held (offsets 388–398: `65`). */
|
||||
val META_SHIFT_BITS: Int = KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON
|
||||
|
||||
/** Unicode's largest code point; above it `writeCodePoint` throws. */
|
||||
private const val MAX_CODE_POINT: Int = 0x10FFFF
|
||||
private const val SURROGATE_FIRST: Int = 0xD800
|
||||
private const val SURROGATE_LAST: Int = 0xDFFF
|
||||
|
||||
/** `LF`; a terminal Enter is `CR`, never `LF` (repo-wide gotcha, and Termux remaps it identically). */
|
||||
private const val LINE_FEED: Int = 10
|
||||
private const val CARRIAGE_RETURN: Int = 13
|
||||
|
||||
/** Ctrl-letter offsets: lowercase maps `a`→1 via `cp - 96`, uppercase `A`→1 via `cp - 64`. */
|
||||
private const val CTRL_LOWERCASE_OFFSET: Int = 96
|
||||
private const val CTRL_UPPERCASE_OFFSET: Int = 64
|
||||
|
||||
/**
|
||||
* The Termux `KEYMOD_*` bitset for a key press — the second argument of [KeyHandler.getCode], which
|
||||
* stays the single authority for the DECCKM/keypad key tables (plan §6.3).
|
||||
*/
|
||||
fun keyModifiers(ctrl: Boolean, alt: Boolean, shift: Boolean, numLock: Boolean): Int {
|
||||
var mod = 0
|
||||
if (ctrl) mod = mod or KeyHandler.KEYMOD_CTRL
|
||||
if (alt) mod = mod or KeyHandler.KEYMOD_ALT
|
||||
if (shift) mod = mod or KeyHandler.KEYMOD_SHIFT
|
||||
if (numLock) mod = mod or KeyHandler.KEYMOD_NUM_LOCK
|
||||
return mod
|
||||
}
|
||||
|
||||
/**
|
||||
* The meta state to hand `KeyEvent.getUnicodeChar(...)`, mirroring `TerminalView.onKeyDown` offsets
|
||||
* 357–418: drop the Ctrl bits (and the Alt bits unless NumLock is on) so the keymap yields the base
|
||||
* character rather than nothing, then re-assert Shift so the character comes back capitalised.
|
||||
*/
|
||||
fun effectiveMetaState(metaState: Int, shift: Boolean, numLockOn: Boolean): Int {
|
||||
var bitsToClear = META_CTRL_MASK
|
||||
if (!numLockOn) bitsToClear = bitsToClear or META_ALT_BITS
|
||||
var effective = metaState and bitsToClear.inv()
|
||||
if (shift) effective = effective or META_SHIFT_BITS
|
||||
return effective
|
||||
}
|
||||
|
||||
/**
|
||||
* One key press → the exact bytes the shell should receive, or `null` when the code point is not
|
||||
* representable (dropped rather than thrown).
|
||||
*
|
||||
* Order matters and matches Termux: the Ctrl transform runs first, then the dead-key aliases, then
|
||||
* the optional `ESC` prefix for Alt.
|
||||
*/
|
||||
fun encodeCodePoint(codePoint: Int, ctrlDown: Boolean, altDown: Boolean): String? {
|
||||
if (codePoint < 0) return null
|
||||
val controlled = if (ctrlDown) applyControl(codePoint) else codePoint
|
||||
val resolved = applyDeadKeyAlias(controlled)
|
||||
if (!isEncodable(resolved)) return null
|
||||
val prefix = if (altDown) ESC else ""
|
||||
return prefix + String(Character.toChars(resolved))
|
||||
}
|
||||
|
||||
/**
|
||||
* An IME commit (`commitText`/`finishComposingText`) → the bytes for the wire.
|
||||
*
|
||||
* This is the NET effect of Termux's `TerminalView$2.sendTextToTerminal`, which walks code points,
|
||||
* remaps `LF`→`CR`, re-encodes C0 characters as "Ctrl + printable" and then feeds them back through
|
||||
* the Ctrl transform — a round trip that provably returns the original C0 byte. Doing the net thing
|
||||
* directly keeps one obvious implementation instead of two that must agree.
|
||||
*
|
||||
* Content is otherwise passed through verbatim (invariant #1/#9): CJK, emoji and surrogate pairs are
|
||||
* never filtered, uppercased or normalised.
|
||||
*/
|
||||
fun encodeText(text: CharSequence): String {
|
||||
if (text.isEmpty()) return ""
|
||||
val out = StringBuilder(text.length)
|
||||
var index = 0
|
||||
while (index < text.length) {
|
||||
val codePoint = Character.codePointAt(text, index)
|
||||
index += Character.charCount(codePoint)
|
||||
out.appendCodePoint(if (codePoint == LINE_FEED) CARRIAGE_RETURN else codePoint)
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** `TerminalView.inputCodePoint` offsets 148–318, alias for alias. */
|
||||
private fun applyControl(codePoint: Int): Int = when (codePoint) {
|
||||
in 'a'.code..'z'.code -> codePoint - CTRL_LOWERCASE_OFFSET
|
||||
in 'A'.code..'Z'.code -> codePoint - CTRL_UPPERCASE_OFFSET
|
||||
' '.code, '2'.code -> 0
|
||||
'['.code, '3'.code -> 27
|
||||
'\\'.code, '4'.code -> 28
|
||||
']'.code, '5'.code -> 29
|
||||
'^'.code, '6'.code -> 30
|
||||
'_'.code, '7'.code, '/'.code -> 31
|
||||
'8'.code -> 127
|
||||
else -> codePoint
|
||||
}
|
||||
|
||||
/** `TerminalView.inputCodePoint` offsets 319–374 — spacing modifier letters → their ASCII twins. */
|
||||
private fun applyDeadKeyAlias(codePoint: Int): Int = when (codePoint) {
|
||||
MODIFIER_CIRCUMFLEX -> '^'.code
|
||||
MODIFIER_GRAVE -> '`'.code
|
||||
MODIFIER_TILDE -> '~'.code
|
||||
else -> codePoint
|
||||
}
|
||||
|
||||
private fun isEncodable(codePoint: Int): Boolean =
|
||||
codePoint in 0..MAX_CODE_POINT && codePoint !in SURROGATE_FIRST..SURROGATE_LAST
|
||||
|
||||
/** U+02C6 MODIFIER LETTER CIRCUMFLEX ACCENT. */
|
||||
private const val MODIFIER_CIRCUMFLEX: Int = 710
|
||||
|
||||
/** U+02CB MODIFIER LETTER GRAVE ACCENT. */
|
||||
private const val MODIFIER_GRAVE: Int = 715
|
||||
|
||||
/** U+02DC SMALL TILDE. */
|
||||
private const val MODIFIER_TILDE: Int = 732
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyEvent
|
||||
|
||||
/**
|
||||
* The one outlet every input source in this module funnels into — hardware keys (via
|
||||
* [WebTermTerminalViewClient]), the soft keyboard (via [RemoteTerminalInputConnection]) and layout
|
||||
* changes (via [RemoteTerminalHostView]).
|
||||
*
|
||||
* It exists so the android-bound classes never reach for a `RemoteTerminalSession` (or, worse, Termux's
|
||||
* `mTermSession`) directly: [RemoteTerminalView] is the only implementor and it is the single place
|
||||
* where "which session is currently bound" is known. That keeps the §6.3 ordered-send model intact —
|
||||
* everything ends at `RemoteTerminalSession.writeInput` → `engineSend(Input(..))`.
|
||||
*/
|
||||
internal interface TerminalInputSink {
|
||||
|
||||
/**
|
||||
* `:app`'s hardware-chord outlet (`RemoteTerminalView.onKeyCommand` → `HardwareKeyRouter`). Returns
|
||||
* true when `:app` both handled AND already sent the key, so the decoder must not send it again.
|
||||
*/
|
||||
fun appConsumesKey(keyCode: Int, event: KeyEvent): Boolean
|
||||
|
||||
/** The emulator's live DECCKM / keypad bits, or null when no session is bound yet. */
|
||||
fun cursorModes(): TerminalCursorModes?
|
||||
|
||||
/** Raw bytes onto the wire, verbatim (invariant #9 — never filtered). */
|
||||
fun sendInput(data: String)
|
||||
|
||||
/** The user tapped the terminal: take focus and raise the soft keyboard. */
|
||||
fun onTapped()
|
||||
|
||||
/** The host view was laid out at this pixel size (drives the §6.4 resize path). */
|
||||
fun onViewSize(widthPx: Int, heightPx: Int)
|
||||
}
|
||||
|
||||
/** The two emulator mode bits the Termux key tables need. Snapshotted so the decoder stays pure. */
|
||||
internal data class TerminalCursorModes(val cursorKeysApplication: Boolean, val keypadApplication: Boolean)
|
||||
@@ -0,0 +1,131 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyEvent
|
||||
import com.termux.terminal.KeyHandler
|
||||
|
||||
/**
|
||||
* What to do with one hardware key press.
|
||||
*
|
||||
* There is deliberately **no variant meaning "let the stock Termux path handle it"**. Every stock
|
||||
* continuation past `TerminalViewClient.onKeyDown` dereferences `mTermSession`, which is null forever in
|
||||
* this app (plan §6.1 — no local subprocess) and cannot be set (`TerminalSession` is `final` and its
|
||||
* constructor builds a process I/O pipeline we must not have). Verified in `terminal-view-v0.118.0.aar`
|
||||
* → `TerminalView.class`: `mTermSession.write(getCharacters())` at offset 149–160, `handleKeyCode` →
|
||||
* `mTermSession.getEmulator()` at offset 15–19, `inputCodePoint` → `mTermSession.writeCodePoint` at
|
||||
* offset 375–384. The absence of that variant is the fix: it makes the crash unrepresentable **on the key
|
||||
* path**. It says nothing about the touch path, which bypasses `TerminalViewClient` entirely — see
|
||||
* [RemoteTerminalHostView]'s stock-method audit for what guards that.
|
||||
*/
|
||||
internal sealed interface KeyRouting {
|
||||
|
||||
/** Consume the key and put exactly these bytes on the wire. */
|
||||
data class Send(val bytes: String) : KeyRouting
|
||||
|
||||
/** Consume the key and send nothing (already sent by `:app`, or the key has no terminal meaning). */
|
||||
data object Consumed : KeyRouting
|
||||
|
||||
/**
|
||||
* Hand the key to the HOST view's `super` — i.e. the Activity's BACK/volume/system handling. Never
|
||||
* to Termux's terminal path.
|
||||
*/
|
||||
data object DeferToHostView : KeyRouting
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of an `android.view.KeyEvent` the decision needs, lifted out so the decision itself is pure
|
||||
* and unit-testable without a device or a mocked framework class. [unicodeChar] is the keymap lookup
|
||||
* (`KeyEvent.getUnicodeChar(metaState)`), deferred because the meta state it takes is itself derived.
|
||||
*/
|
||||
internal class KeyEventFacts(
|
||||
val isSystemKey: Boolean,
|
||||
val ctrlPressed: Boolean,
|
||||
val altPressed: Boolean,
|
||||
val shiftPressed: Boolean,
|
||||
val numLockOn: Boolean,
|
||||
val functionPressed: Boolean,
|
||||
val metaState: Int,
|
||||
val unicodeChar: (metaState: Int) -> Int,
|
||||
) {
|
||||
companion object {
|
||||
/** Read the facts off a real event. The only android-touching line in the key path. */
|
||||
fun of(event: KeyEvent): KeyEventFacts = KeyEventFacts(
|
||||
isSystemKey = event.isSystem,
|
||||
ctrlPressed = event.isCtrlPressed,
|
||||
altPressed = event.isAltPressed,
|
||||
shiftPressed = event.isShiftPressed,
|
||||
numLockOn = event.isNumLockOn,
|
||||
functionPressed = event.isFunctionPressed,
|
||||
metaState = event.metaState,
|
||||
unicodeChar = { meta -> event.getUnicodeChar(meta) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a key press into a [KeyRouting], reproducing the useful half of `TerminalView.onKeyDown`
|
||||
* (offsets 259–582) while routing every outcome away from `mTermSession`.
|
||||
*
|
||||
* Termux's [KeyHandler] stays the authority for the key tables, so DECCKM (`ESC O A` under
|
||||
* application-cursor mode) and the keypad/modifier variants keep matching the web and iOS clients
|
||||
* exactly — plan §6.3 forbids hand-rolling that table.
|
||||
*/
|
||||
internal object TerminalKeyDecoder {
|
||||
|
||||
/**
|
||||
* @param appConsumed `:app`'s hardware-chord outlet already handled and SENT this key
|
||||
* (`RemoteTerminalView.onKeyCommand` → `HardwareKeyRouter`); emitting again would double-type.
|
||||
* @param cursorKeysApplication the emulator's live DECCKM bit.
|
||||
* @param keypadApplication the emulator's live DECKPAM bit.
|
||||
*/
|
||||
fun decodeKeyDown(
|
||||
keyCode: Int,
|
||||
facts: KeyEventFacts,
|
||||
appConsumed: Boolean,
|
||||
cursorKeysApplication: Boolean,
|
||||
keypadApplication: Boolean,
|
||||
): KeyRouting {
|
||||
if (appConsumed) return KeyRouting.Consumed
|
||||
// BACK / HOME / volume must still reach the Activity, or the terminal screen becomes a trap.
|
||||
if (facts.isSystemKey) return KeyRouting.DeferToHostView
|
||||
|
||||
// 1. The Termux key table (arrows, Enter, Tab, F-keys, Home/End, keypad) — DECCKM-correct.
|
||||
// Gated on !Fn exactly like TerminalView.onKeyDown offsets 319-333.
|
||||
if (!facts.functionPressed) {
|
||||
val keyMod = TerminalInputBytes.keyModifiers(
|
||||
ctrl = facts.ctrlPressed,
|
||||
alt = facts.altPressed,
|
||||
shift = facts.shiftPressed,
|
||||
numLock = facts.numLockOn,
|
||||
)
|
||||
val mapped = KeyHandler.getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication)
|
||||
if (!mapped.isNullOrEmpty()) return KeyRouting.Send(mapped)
|
||||
}
|
||||
|
||||
// 2. Otherwise it is a printable key: ask the keymap, then apply the Ctrl/Alt transform.
|
||||
val effectiveMeta = TerminalInputBytes.effectiveMetaState(
|
||||
metaState = facts.metaState,
|
||||
shift = facts.shiftPressed,
|
||||
numLockOn = facts.numLockOn,
|
||||
)
|
||||
val raw = facts.unicodeChar(effectiveMeta)
|
||||
if (raw == 0) return KeyRouting.Consumed
|
||||
// A dead key (accent) — Termux composes it with the NEXT key using a package-private view field
|
||||
// we cannot reach. Swallow it rather than emitting a lone spacing accent; real composition
|
||||
// arrives through the IME path (RemoteTerminalInputConnection), which is the mobile text path.
|
||||
if (raw and TerminalInputBytes.COMBINING_ACCENT != 0) return KeyRouting.Consumed
|
||||
|
||||
val bytes = TerminalInputBytes.encodeCodePoint(
|
||||
codePoint = raw,
|
||||
ctrlDown = facts.ctrlPressed,
|
||||
altDown = facts.altPressed,
|
||||
) ?: return KeyRouting.Consumed
|
||||
return KeyRouting.Send(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Key-up carries no bytes (the shell saw everything on key-down), so it is consumed — except for
|
||||
* system keys, whose `up` must reach the Activity for BACK to fire.
|
||||
*/
|
||||
fun decodeKeyUp(facts: KeyEventFacts): KeyRouting =
|
||||
if (facts.isSystemKey) KeyRouting.DeferToHostView else KeyRouting.Consumed
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
/**
|
||||
* The real cell metrics of the stock Termux renderer.
|
||||
*
|
||||
* `TerminalRenderer.mFontWidth` / `mFontLineSpacing` are package-private fields, but the pinned
|
||||
* artifact also ships **public accessors** — `public float getFontWidth()` and
|
||||
* `public int getFontLineSpacing()` (verified with `javap -p` on
|
||||
* `terminal-view-v0.118.0.aar` → `TerminalRenderer.class`). So the metrics are read through plain public
|
||||
* API: no reflection (which would break under R8/a version bump silently), no same-package accessor
|
||||
* shim, and no re-measured `Paint` (which would drift from whatever the renderer actually draws with —
|
||||
* exactly the cols×rows drift plan risk R5 warns about).
|
||||
*/
|
||||
internal data class TerminalCellMetrics(val fontWidthPx: Float, val lineSpacingPx: Int)
|
||||
|
||||
/**
|
||||
* BLOCKER 2 — the thing that makes `resize` happen at all.
|
||||
*
|
||||
* Nothing in the app ever called `RemoteTerminalSession.updateSize`, and the stock fallback cannot
|
||||
* help: `TerminalView.updateSize()` early-returns when `mTermSession == null` (offsets 18–25 of
|
||||
* `TerminalView.class`), which is permanently our case. So the server PTY sat at the 80×24 it is created
|
||||
* with and every full-screen TUI rendered into the wrong box.
|
||||
*
|
||||
* This driver is the replacement path: layout size + real cell metrics → the pure, already-tested
|
||||
* [TerminalGridMath.computeGridSize] → [applyGrid]. It is deliberately **pure of android**: the view
|
||||
* supplies the pixels and the metrics, so the arithmetic and the skip-logic are JVM-unit-testable.
|
||||
*
|
||||
* ### Why the dedup lives here as well as on the confined writer
|
||||
* `RemoteTerminalSession` already refuses to re-emit an unchanged grid, but layout fires far more often
|
||||
* than the grid changes (every scroll-inset, IME show/hide, and each frame of a fold/multi-window drag).
|
||||
* Stopping at the source keeps that churn off the command channel entirely, so rotation costs exactly
|
||||
* one `Resize` frame per real grid change. The dedup is on the GRID, not the pixel size: growing by a
|
||||
* few sub-cell pixels is not a resize.
|
||||
*
|
||||
* ### …and why a re-assert has to be FLAGGED rather than just forgetting the memo
|
||||
* Two dedups in series can cancel the §6.4 reclaim entirely. Clearing this memo only re-opens the
|
||||
* *first* gate; the identical grid then dies on the writer's own `lastSentDims`, which survives the
|
||||
* rebind, so the returning device never re-asserts its size on the wire and the other device keeps the
|
||||
* shared PTY. [reassertLastGrid] therefore re-pushes with `isReassert = true`, which the writer honours
|
||||
* as "emit even though nothing changed", and deliberately LEAVES the memo in place so ordinary layout
|
||||
* churn still cannot ride along behind the reclaim.
|
||||
*
|
||||
* Not thread-safe by design — it is driven from `onSizeChanged`, i.e. the main thread only.
|
||||
*/
|
||||
internal class TerminalResizeDriver(
|
||||
private val cellMetrics: () -> TerminalCellMetrics?,
|
||||
private val applyGrid: (grid: TerminalGridSize, isReassert: Boolean) -> Unit,
|
||||
) {
|
||||
private var lastGrid: TerminalGridSize? = null
|
||||
|
||||
/**
|
||||
* A layout pass reported [widthPx]×[heightPx]. Resolves the grid and pushes it iff it is valid and
|
||||
* actually new. Pre-layout (zero) sizes and a not-yet-built renderer are dropped silently — they are
|
||||
* the normal startup order, not an error.
|
||||
*/
|
||||
fun onViewSize(widthPx: Int, heightPx: Int, horizontalPaddingPx: Int, verticalPaddingPx: Int) {
|
||||
val metrics = cellMetrics() ?: return
|
||||
val grid = TerminalGridMath.computeGridSize(
|
||||
viewWidthPx = widthPx,
|
||||
viewHeightPx = heightPx,
|
||||
horizontalPaddingPx = horizontalPaddingPx,
|
||||
verticalPaddingPx = verticalPaddingPx,
|
||||
fontWidthPx = metrics.fontWidthPx,
|
||||
fontLineSpacingPx = metrics.lineSpacingPx,
|
||||
) ?: return
|
||||
if (grid == lastGrid) return
|
||||
lastGrid = grid
|
||||
applyGrid(grid, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-push the last measured grid as a forced re-assert (§6.4 latest-writer-wins).
|
||||
*
|
||||
* Fires from a lifecycle / focus / pane-show callback, not from `onSizeChanged` — there is no new
|
||||
* layout pass to piggyback on, so the push comes from the memo rather than from a re-measure. Before
|
||||
* anything has been measured there is nothing to assert and this is a no-op: the first real layout
|
||||
* will push anyway.
|
||||
*
|
||||
* The memo is intentionally NOT cleared — see the class doc. Clearing it would let the next identical
|
||||
* layout pass push a second time.
|
||||
*/
|
||||
fun reassertLastGrid() {
|
||||
val grid = lastGrid ?: return
|
||||
applyGrid(grid, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import com.termux.terminal.KeyHandler
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* BLOCKER — the scroll path that had to be taken away from the stock view.
|
||||
*
|
||||
* `TerminalView.doScroll` (offsets 56-79 of the pinned `terminal-view-v0.118.0.aar`) turns a scroll into
|
||||
* `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` **whenever the alternate screen buffer is active**, and
|
||||
* `handleKeyCode` offsets 15-19 are `mTermSession.getEmulator()` with no null guard. `mEmulator` IS
|
||||
* guarded (offset 4); `mTermSession` is not. Since this app has no `TerminalSession` at all (plan §6.1 —
|
||||
* no local subprocess) that is a hard NPE on the UI thread, i.e. a process crash, for one finger-swipe
|
||||
* inside vim / htop / less / man / the git-log pager / tmux — every TUI in the plan's own §7 device-QA
|
||||
* list. `TerminalViewClient` has no callback anywhere on that path, so installing a client cannot help.
|
||||
* Three stock entry points reach it: `TerminalView$1.onScroll` (offsets 103-110),
|
||||
* `TerminalView$1$1.run` (the fling runnable, offset 120) and `onGenericMotionEvent` (offsets 43-55).
|
||||
*
|
||||
* The fix is ownership: [RemoteTerminalHostView] claims the vertical drag before the stock view sees it,
|
||||
* so all three entry points become unreachable, and this class reproduces what `doScroll` *should* have
|
||||
* done — the same three branches, in the same order, with the same arithmetic:
|
||||
*
|
||||
* | stock `doScroll` offsets | condition | this class |
|
||||
* |--------------------------|-------------------------------|---------------------------------------------|
|
||||
* | 26-53 | `isMouseTrackingActive()` | [TerminalScrollTarget.reportMouseWheel] |
|
||||
* | 56-79 (**the crash**) | `isAlternateBufferActive()` | [TerminalScrollTarget.sendKeys], DECCKM-correct |
|
||||
* | 86-130 | otherwise | [TerminalScrollTarget.scrollTranscript] |
|
||||
*
|
||||
* The mode bits are read off the **bound emulator** — which is the very object stock reaches for via
|
||||
* `mTermSession.getEmulator()`, so the arrow form (CSI vs SS3) is identical, not re-derived. Termux's
|
||||
* [KeyHandler] stays the authority for the key table (plan §6.3 forbids hand-rolling it).
|
||||
*
|
||||
* Pure of `android.view.View`: the pixels arrive as [TouchFacts] and the effects leave through
|
||||
* [TerminalScrollTarget], so the whole decision is JVM-unit-testable — the same shape [KeyEventFacts]
|
||||
* gives the key path.
|
||||
*/
|
||||
internal class TerminalScrollGesture(
|
||||
private val touchSlopPx: Int,
|
||||
private val cellHeightPx: () -> Int,
|
||||
private val target: TerminalScrollTarget,
|
||||
) {
|
||||
private var owned = false
|
||||
private var lastY = 0f
|
||||
private var travelSinceDownPx = 0f
|
||||
private var unspentTravelPx = 0f
|
||||
|
||||
/** True once this gesture belongs to us; the stock view has been sent `ACTION_CANCEL` by then. */
|
||||
val isOwned: Boolean get() = owned
|
||||
|
||||
/**
|
||||
* Feed one touch sample.
|
||||
*
|
||||
* @return true once the gesture is ours. `ViewGroup` reads that from `onInterceptTouchEvent` as
|
||||
* "cancel the child and route the rest here", which is exactly the guarantee we need: the stock
|
||||
* `mGestureRecognizer` never sees a scroll, so it can neither call `doScroll` nor start a fling.
|
||||
*/
|
||||
fun onTouch(facts: TouchFacts): Boolean = when (facts.phase) {
|
||||
TouchPhase.DOWN -> {
|
||||
reset(facts.y)
|
||||
false
|
||||
}
|
||||
|
||||
TouchPhase.MOVE -> onMove(facts)
|
||||
|
||||
TouchPhase.END -> {
|
||||
val wasOwned = owned
|
||||
reset(facts.y)
|
||||
wasOwned
|
||||
}
|
||||
|
||||
TouchPhase.OTHER -> owned
|
||||
}
|
||||
|
||||
/**
|
||||
* One external-mouse wheel notch, replacing stock `onGenericMotionEvent` offsets 43-55 (which hands
|
||||
* `±MOUSE_WHEEL_ROWS` straight to the crashing `doScroll`).
|
||||
*/
|
||||
fun onMouseWheel(scrollUp: Boolean) {
|
||||
scrollRows(if (scrollUp) -MOUSE_WHEEL_ROWS else MOUSE_WHEEL_ROWS)
|
||||
}
|
||||
|
||||
/** Drop any half-finished gesture (view detach / emulator rebind). */
|
||||
fun reset() {
|
||||
reset(lastY)
|
||||
}
|
||||
|
||||
private fun reset(y: Float) {
|
||||
owned = false
|
||||
lastY = y
|
||||
travelSinceDownPx = 0f
|
||||
unspentTravelPx = 0f
|
||||
}
|
||||
|
||||
private fun onMove(facts: TouchFacts): Boolean {
|
||||
// A second finger means a pinch: that gesture belongs to the stock ScaleGestureDetector, which is
|
||||
// safe (TerminalView$1.onScale touches no session) and is where our onScale veto is enforced.
|
||||
if (facts.pointerCount > SINGLE_POINTER) return owned
|
||||
|
||||
// Positive == the finger moved UP the screen, matching GestureDetector's `distanceY`
|
||||
// (`previousFocusY - currentFocusY`) so every sign below is stock's sign.
|
||||
val travelPx = lastY - facts.y
|
||||
lastY = facts.y
|
||||
|
||||
if (!owned) {
|
||||
travelSinceDownPx += travelPx
|
||||
if (abs(travelSinceDownPx) < touchSlopPx) return false
|
||||
owned = true
|
||||
// The slop travel is spent on the claim itself — GestureDetector likewise only starts
|
||||
// reporting distance once the slop is crossed, so a claimed drag does not jump a row.
|
||||
unspentTravelPx = 0f
|
||||
return true
|
||||
}
|
||||
|
||||
val cellHeight = cellHeightPx()
|
||||
// Before the renderer has measured a cell there is no row size to divide by. Keep the gesture
|
||||
// (dropping it now would hand the rest of the drag back to the crashing stock path) and scroll
|
||||
// nothing — which is also what stock does with a not-yet-laid-out view.
|
||||
if (cellHeight <= 0) return true
|
||||
|
||||
unspentTravelPx += travelPx
|
||||
val rows = (unspentTravelPx / cellHeight).toInt()
|
||||
unspentTravelPx -= rows * cellHeight
|
||||
scrollRows(rows)
|
||||
return true
|
||||
}
|
||||
|
||||
/** `doScroll(event, rows)` — same branch order, same per-row loop, minus the `mTermSession` deref. */
|
||||
private fun scrollRows(rows: Int) {
|
||||
if (rows == 0) return
|
||||
val emulator = target.emulator() ?: return
|
||||
val up = rows < 0
|
||||
repeat(abs(rows)) {
|
||||
when {
|
||||
emulator.isMouseTrackingActive -> target.reportMouseWheel(up)
|
||||
emulator.isAlternateBufferActive -> arrowKeyBytes(emulator, up)?.let(target::sendKeys)
|
||||
else -> target.scrollTranscript(up)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What stock `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` would have written — `KeyHandler.getCode` with
|
||||
* the emulator's live DECCKM/keypad bits, so vim and htop still get `ESC O A` rather than `ESC [ A`.
|
||||
*/
|
||||
private fun arrowKeyBytes(emulator: TerminalEmulator, up: Boolean): String? = KeyHandler.getCode(
|
||||
if (up) KeyEvent.KEYCODE_DPAD_UP else KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
NO_KEY_MODIFIERS,
|
||||
emulator.isCursorKeysApplicationMode,
|
||||
emulator.isKeypadApplicationMode,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Stock `onGenericMotionEvent` scrolls three rows per wheel notch. */
|
||||
const val MOUSE_WHEEL_ROWS: Int = 3
|
||||
|
||||
/** `handleKeyCode` is called with a zero key-modifier bitset from `doScroll`. */
|
||||
private const val NO_KEY_MODIFIERS: Int = 0
|
||||
|
||||
private const val SINGLE_POINTER: Int = 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Which part of a gesture a sample belongs to. Cancel counts as an end: the gesture is over either way. */
|
||||
internal enum class TouchPhase { DOWN, MOVE, END, OTHER }
|
||||
|
||||
/**
|
||||
* The subset of a `MotionEvent` the scroll decision needs, lifted out so the decision stays pure and
|
||||
* unit-testable without a device — the same trick [KeyEventFacts] plays for keys.
|
||||
*/
|
||||
internal data class TouchFacts(val phase: TouchPhase, val y: Float, val pointerCount: Int) {
|
||||
companion object {
|
||||
fun of(event: MotionEvent): TouchFacts = TouchFacts(
|
||||
phase = when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> TouchPhase.DOWN
|
||||
MotionEvent.ACTION_MOVE -> TouchPhase.MOVE
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> TouchPhase.END
|
||||
else -> TouchPhase.OTHER
|
||||
},
|
||||
y = event.y,
|
||||
pointerCount = event.pointerCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a resolved scroll lands. Implemented by [RemoteTerminalHostView] over the stock view. */
|
||||
internal interface TerminalScrollTarget {
|
||||
|
||||
/** The bound emulator, or null before bind — the live source of the mode bits `doScroll` reads. */
|
||||
fun emulator(): TerminalEmulator?
|
||||
|
||||
/** Stock `doScroll` offsets 86-130: move the local transcript window by one row and repaint. */
|
||||
fun scrollTranscript(up: Boolean)
|
||||
|
||||
/** Stock `doScroll` offsets 66-79, rewritten to reach the wire instead of a null `TerminalSession`. */
|
||||
fun sendKeys(data: String)
|
||||
|
||||
/** Stock `doScroll` offsets 36-53 → `sendMouseEventCode(event, MOUSE_WHEEL*_BUTTON, true)`. */
|
||||
fun reportMouseWheel(up: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import com.termux.terminal.TerminalSession
|
||||
import com.termux.view.TerminalView
|
||||
import com.termux.view.TerminalViewClient
|
||||
|
||||
/**
|
||||
* The `TerminalViewClient` that was never installed — the fix for the stock **key** paths only.
|
||||
*
|
||||
* `TerminalView.mClient` is dereferenced with **no null guard** on the first line of the paths a user
|
||||
* actually hits (verified with `javap -p -c` on the pinned
|
||||
* `terminal-view-v0.118.0.aar` → `TerminalView.class`):
|
||||
* - `onCreateInputConnection` offset 0–4 → `mClient.isTerminalViewSelected()` — NPE the moment the soft
|
||||
* keyboard attaches.
|
||||
* - `onKeyDown` offset 82–92 → `mClient.onKeyDown(keyCode, event, mTermSession)` — NPE on the first key.
|
||||
* - `onKeyUp` offset 64–70, `onKeyPreIme` offset 68–72, and the gesture callbacks — same shape.
|
||||
*
|
||||
* Installing any client removes the NPE; installing THIS one also keeps the crash from simply moving.
|
||||
* A client that returned false would let the stock method continue into
|
||||
* `mTermSession.write(getCharacters())` (offset 149–160), `handleKeyCode` →
|
||||
* `mTermSession.getEmulator()` (offset 15–19, no guard) or `inputCodePoint` (which silently drops
|
||||
* everything because of its own `mTermSession == null` early return at offset 59–66). `mTermSession` is
|
||||
* null forever — `TerminalSession` is `final` and forking a process is exactly what this app must not do
|
||||
* (plan §6.1). So this client **consumes the key itself** and returns true, which ends `onKeyDown` at
|
||||
* `invalidate(); return true` (offset 97–105) before any of that is reachable. [TerminalKeyDecoder]
|
||||
* encodes that as a type: it has no "hand it back to Termux" outcome.
|
||||
*
|
||||
* ### What this client does NOT cover (the correction that a review caught)
|
||||
* A `TerminalViewClient` only sees the KEY paths. **Touch never passes through it**: `onTouchEvent` →
|
||||
* `mGestureRecognizer` → `TerminalView$1.onScroll` → `doScroll` → `handleKeyCode` →
|
||||
* `mTermSession.getEmulator()` has no client callback anywhere on it, so installing this client does
|
||||
* nothing for the alternate-screen scroll crash. That one is fixed by ownership in
|
||||
* [RemoteTerminalHostView], whose KDoc carries the full enumerated audit of which stock members are
|
||||
* reachable and which are proven safe. Rendering and the cursor stay stock; scrolling and text selection
|
||||
* do not (plan §6.1/§6.5 deviations, both recorded there and on [onLongPress]).
|
||||
*
|
||||
|
||||
* @param sink the single outlet — `:app`'s chord router, the emulator's live cursor modes, and the byte
|
||||
* send pump. See [TerminalInputSink].
|
||||
*/
|
||||
internal class WebTermTerminalViewClient(private val sink: TerminalInputSink) : TerminalViewClient {
|
||||
|
||||
// ── Keys ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param session ALWAYS null here (see the class doc) and therefore ignored — it is only in the
|
||||
* signature because Termux passes its own `mTermSession` through.
|
||||
*/
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent, session: TerminalSession?): Boolean {
|
||||
val modes = sink.cursorModes()
|
||||
val routing = TerminalKeyDecoder.decodeKeyDown(
|
||||
keyCode = keyCode,
|
||||
facts = KeyEventFacts.of(event),
|
||||
appConsumed = sink.appConsumesKey(keyCode, event),
|
||||
cursorKeysApplication = modes?.cursorKeysApplication ?: false,
|
||||
keypadApplication = modes?.keypadApplication ?: false,
|
||||
)
|
||||
return consume(routing)
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean =
|
||||
consume(TerminalKeyDecoder.decodeKeyUp(KeyEventFacts.of(event)))
|
||||
|
||||
/**
|
||||
* @param session ALWAYS null (unreachable in practice — `inputCodePoint` returns before calling us
|
||||
* when `mTermSession` is null). Implemented anyway, returning true, so that even if a future edit
|
||||
* made the path reachable it could still never fall through to `mTermSession.writeCodePoint`.
|
||||
*/
|
||||
override fun onCodePoint(codePoint: Int, ctrlDown: Boolean, session: TerminalSession?): Boolean {
|
||||
TerminalInputBytes.encodeCodePoint(codePoint, ctrlDown, altDown = false)?.let(sink::sendInput)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun consume(routing: KeyRouting): Boolean = when (routing) {
|
||||
is KeyRouting.Send -> {
|
||||
sink.sendInput(routing.bytes)
|
||||
true
|
||||
}
|
||||
|
||||
KeyRouting.Consumed -> true
|
||||
// false → the stock method falls through to `super.onKeyDown/onKeyUp` (offsets 130 / 91), i.e.
|
||||
// plain View handling. It never reaches the terminal path, because we also answer false to
|
||||
// shouldBackButtonBeMappedToEscape below.
|
||||
KeyRouting.DeferToHostView -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* MUST stay false. True would make `TerminalView.onKeyDown` route a system BACK press INTO the
|
||||
* terminal branch (offset 113–136), whose next stop is `mTermSession.write(...)`.
|
||||
*/
|
||||
override fun shouldBackButtonBeMappedToEscape(): Boolean = false
|
||||
|
||||
/** No latched modifier bar in this module — `:app`'s key-bar sends finished byte sequences instead. */
|
||||
override fun readControlKey(): Boolean = false
|
||||
override fun readAltKey(): Boolean = false
|
||||
override fun readShiftKey(): Boolean = false
|
||||
override fun readFnKey(): Boolean = false
|
||||
|
||||
/** Termux's Ctrl+Space quirk workaround is for its own extra-keys bar; not applicable. */
|
||||
override fun shouldUseCtrlSpaceWorkaround(): Boolean = false
|
||||
|
||||
// ── IME hints ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Only consulted by the stock `onCreateInputConnection`, which [RemoteTerminalHostView] overrides —
|
||||
* but it must answer true so that if anything ever does reach the stock path it configures a text
|
||||
* editor rather than declaring the view unselected.
|
||||
*/
|
||||
override fun isTerminalViewSelected(): Boolean = true
|
||||
|
||||
/** Terminals want no autocorrect, no suggestions and no learned-word history of what you typed. */
|
||||
override fun shouldEnforceCharBasedInput(): Boolean = true
|
||||
|
||||
// ── Gestures ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Tapping the terminal takes focus and raises the soft keyboard (Termux's own app does the same). */
|
||||
override fun onSingleTapUp(event: MotionEvent?) = sink.onTapped()
|
||||
|
||||
/**
|
||||
* Pinch-zoom is declined: the font size is what the grid maths divides by, so changing it mid-flight
|
||||
* would need a resize round-trip on every pinch frame. Returning the identity scale pins
|
||||
* [TerminalView]'s `mScaleFactor` at 1 and leaves the metrics stable.
|
||||
*/
|
||||
override fun onScale(scale: Float): Float = NO_SCALE
|
||||
|
||||
/**
|
||||
* MUST stay true — text selection is **cut**, a recorded deviation from plan §6.5.
|
||||
*
|
||||
* `TerminalView$1.onLongPress` offsets 14-30 are `if (mClient.onLongPress(e)) return;`, so true is the
|
||||
* one supported way to stop `startTextSelectionMode` from arming. It has to be stopped, because the
|
||||
* stock selection ActionMode is a crash rather than a feature without a `TerminalSession`:
|
||||
* `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 (**Copy**) are
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 (**Paste**) are
|
||||
* `mTermSession.onPasteTextFromClipboard()` — both unguarded, both on the only two buttons the mode
|
||||
* exists for. Selecting text would work; the moment the user tapped Copy the process would die.
|
||||
*
|
||||
* The controller itself is otherwise session-free (it reads `TerminalBuffer.getSelectedText`), so a
|
||||
* clipboard path is buildable — but it needs its own ActionMode, its own copy action and its own
|
||||
* selection highlight, i.e. a feature, not a guard. Until then a long press is a no-op instead of a
|
||||
* loaded gun, and the deviation is reported to the plan owner rather than left silently dead.
|
||||
*/
|
||||
/**
|
||||
* Consume the long press, which suppresses stock text selection.
|
||||
*
|
||||
* **RECORDED DEVIATION from plan §6.5** ("Selection/copy: Termux TextSelectionCursorController +
|
||||
* Android ActionMode → ClipboardManager"), and a KNOWN FEATURE GAP — not an oversight, and not the
|
||||
* cheaper of two equal options. An adversarial review asked for stock selection to be RESTORED after
|
||||
* an earlier revision made it unreachable. Disassembling the pinned artifact shows restoring it would
|
||||
* only relocate the crash rather than remove it: the ActionMode's own handlers dereference the absent
|
||||
* session. `TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 are
|
||||
* `mTermSession.onCopyTextToClipboard(...)` and offsets 126-136 are
|
||||
* `mTermSession.onPasteTextFromClipboard()`. So `startTextSelectionMode` being reachable buys a
|
||||
* selection UI whose **Copy button is an NPE** — strictly worse than no selection, because the user
|
||||
* loses their work to a crash instead of finding the affordance absent.
|
||||
*
|
||||
* `mTermSession` cannot be supplied: `TerminalSession` is `final`, its constructor builds a
|
||||
* process-bound `MainThreadHandler`, and this app deliberately has no local subprocess (plan §6.1).
|
||||
*
|
||||
* The real fix is a first-party selection layer that reads the emulator's `TerminalBuffer` and talks
|
||||
* to `ClipboardManager` directly, bypassing `TextSelectionCursorController` entirely. That is a
|
||||
* feature, not a crash fix, so it is tracked in DEVICE_QA_CHECKLIST rather than smuggled in here.
|
||||
* Until then: copy-out is unavailable, and paste-in still works through the IME.
|
||||
*/
|
||||
override fun onLongPress(event: MotionEvent?): Boolean = true
|
||||
|
||||
override fun copyModeChanged(copyMode: Boolean) = Unit
|
||||
|
||||
/** Only fired from the stock `updateSize`, which is dead for us; our binding path is explicit. */
|
||||
override fun onEmulatorSet() = Unit
|
||||
|
||||
// ── Logging: intentionally inert ─────────────────────────────────────────────────────────────────
|
||||
// Termux funnels key codes, typed characters and IME text through these. Terminal content and
|
||||
// keystrokes are exactly what must never reach logcat (plan §8, "untrusted server strings" / no
|
||||
// secret leakage), so every one of them is a no-op — matching NoOpTerminalSessionClient.
|
||||
|
||||
override fun logError(tag: String?, message: String?) = Unit
|
||||
override fun logWarn(tag: String?, message: String?) = Unit
|
||||
override fun logInfo(tag: String?, message: String?) = Unit
|
||||
override fun logDebug(tag: String?, message: String?) = Unit
|
||||
override fun logVerbose(tag: String?, message: String?) = Unit
|
||||
override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) = Unit
|
||||
override fun logStackTrace(tag: String?, e: Exception?) = Unit
|
||||
|
||||
private companion object {
|
||||
/** The identity scale factor — declines pinch-zoom without disturbing the renderer. */
|
||||
const val NO_SCALE: Float = 1.0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.View
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.CELL_HEIGHT_PX
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_APPLICATION_CURSOR_KEYS
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_MOUSE_TRACKING
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.down
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.mouseWheel
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.move
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.up
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
|
||||
/**
|
||||
* The host frame's ownership of scrolling and autofill — the two stock paths that would otherwise NPE
|
||||
* (see [StockTerminalViewHazardTest] for the hazard itself, executed).
|
||||
*
|
||||
* The touch calls here mirror exactly what `ViewGroup.dispatchTouchEvent` does: `onInterceptTouchEvent`
|
||||
* is consulted for every event while a child holds the touch target; the instant it answers **true** the
|
||||
* child is sent `ACTION_CANCEL`, the target is dropped, and every later event of the gesture goes to the
|
||||
* frame's own `onTouchEvent`. So "intercept returned true" is precisely the statement "the stock
|
||||
* `TerminalView` will never see the rest of this drag" — which is what keeps `doScroll` unreachable.
|
||||
*/
|
||||
class RemoteTerminalHostViewTest {
|
||||
|
||||
private lateinit var host: RemoteTerminalHostView
|
||||
private lateinit var sink: RecordingInputSink
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
// The renderer measures its cell box in the constructor, so this must precede the view.
|
||||
StockTerminalViewFixture.mockCellMetrics()
|
||||
host = RemoteTerminalHostView(StockTerminalViewFixture.context())
|
||||
sink = RecordingInputSink()
|
||||
host.sink = sink
|
||||
}
|
||||
|
||||
@AfterEach fun tearDown() = unmockkAll()
|
||||
|
||||
private fun bind(vararg setupSequences: String): TerminalEmulator {
|
||||
val emulator = StockTerminalViewFixture.emulator()
|
||||
setupSequences.forEach { emulator.feed(it) }
|
||||
host.bindEmulator(emulator)
|
||||
return emulator
|
||||
}
|
||||
|
||||
/** Drag the finger UP by [pixels] — the direction that scrolls toward newer output. */
|
||||
private fun dragUp(pixels: Int) {
|
||||
assertFalse(host.onInterceptTouchEvent(down(START_Y)), "a touch-down alone is not a scroll")
|
||||
// The claim consumes the slop travel, exactly like GestureDetector.
|
||||
assertTrue(host.onInterceptTouchEvent(move(START_Y - CLAIM_TRAVEL_PX)), "the drag must be claimed")
|
||||
host.onTouchEvent(move(START_Y - CLAIM_TRAVEL_PX - pixels))
|
||||
host.onTouchEvent(up(START_Y - CLAIM_TRAVEL_PX - pixels))
|
||||
}
|
||||
|
||||
// ── Finding 1: the alternate-screen scroll crash ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a swipe inside an alternate-screen TUI emits arrow keys instead of reaching the stock scroll`() {
|
||||
bind(ENTER_ALTERNATE_BUFFER)
|
||||
|
||||
dragUp(pixels = 2 * CELL_HEIGHT_PX)
|
||||
|
||||
// Stock would have run doScroll -> handleKeyCode -> mTermSession.getEmulator() -> NPE.
|
||||
assertEquals(listOf("\u001b[B", "\u001b[B"), sink.sent, "two cells of travel == two DOWN arrows")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the arrows follow the emulator's live DECCKM mode`() {
|
||||
bind(ENTER_ALTERNATE_BUFFER, ENABLE_APPLICATION_CURSOR_KEYS)
|
||||
|
||||
dragUp(pixels = CELL_HEIGHT_PX)
|
||||
|
||||
assertEquals(listOf("\u001bOB"), sink.sent, "vim/htop in application-cursor mode need SS3")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dragging the other way inside an alternate-screen TUI emits the opposite arrow`() {
|
||||
bind(ENTER_ALTERNATE_BUFFER)
|
||||
|
||||
assertFalse(host.onInterceptTouchEvent(down(START_Y)))
|
||||
assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX)))
|
||||
host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + CELL_HEIGHT_PX))
|
||||
|
||||
assertEquals(listOf("\u001b[A"), sink.sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an external mouse wheel inside an alternate-screen TUI also stays off the stock scroll path`() {
|
||||
bind(ENTER_ALTERNATE_BUFFER)
|
||||
|
||||
// Stock onGenericMotionEvent offsets 43-55 hand a +-3 row scroll straight to doScroll.
|
||||
assertTrue(host.dispatchGenericMotionEvent(mouseWheel(scrollUp = true)))
|
||||
|
||||
assertEquals(List(MOUSE_WHEEL_ROWS) { "\u001b[A" }, sink.sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a two-finger gesture is left to the stock pinch detector`() {
|
||||
bind(ENTER_ALTERNATE_BUFFER)
|
||||
|
||||
assertFalse(host.onInterceptTouchEvent(down(START_Y)))
|
||||
assertFalse(
|
||||
host.onInterceptTouchEvent(move(START_Y - CLAIM_TRAVEL_PX, pointers = 2)),
|
||||
"claiming a pinch would kill zoom handling for a scroll we are not doing",
|
||||
)
|
||||
assertTrue(sink.sent.isEmpty())
|
||||
}
|
||||
|
||||
// ── The two non-crashing stock branches must still behave like stock ─────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `on the normal buffer the drag scrolls the local transcript and sends nothing`() {
|
||||
val emulator = bind()
|
||||
repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") }
|
||||
|
||||
// Drag DOWN — the direction that walks back into scrollback.
|
||||
assertFalse(host.onInterceptTouchEvent(down(START_Y)))
|
||||
assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX)))
|
||||
host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + 3 * CELL_HEIGHT_PX))
|
||||
|
||||
assertEquals(-3, host.stockView.topRow, "three cells of travel == three transcript rows")
|
||||
assertTrue(sink.sent.isEmpty(), "scrollback is local; the shell must not see arrow keys")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transcript scrolling stops at the ends of the buffer`() {
|
||||
val emulator = bind()
|
||||
repeat(SCROLLED_OFF_LINES) { emulator.feed("line\r\n") }
|
||||
val transcriptRows = emulator.screen.activeTranscriptRows
|
||||
|
||||
assertFalse(host.onInterceptTouchEvent(down(START_Y)))
|
||||
assertTrue(host.onInterceptTouchEvent(move(START_Y + CLAIM_TRAVEL_PX)))
|
||||
host.onTouchEvent(move(START_Y + CLAIM_TRAVEL_PX + OVERSCROLL_CELLS * CELL_HEIGHT_PX))
|
||||
|
||||
assertEquals(-transcriptRows, host.stockView.topRow, "cannot scroll past the oldest retained row")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mouse-reporting TUI still gets wheel buttons, not arrows`() {
|
||||
val emulator = bind(ENTER_ALTERNATE_BUFFER, ENABLE_MOUSE_TRACKING)
|
||||
assertTrue(emulator.isMouseTrackingActive)
|
||||
|
||||
dragUp(pixels = CELL_HEIGHT_PX)
|
||||
|
||||
// Stock doScroll offsets 26-53 prefer the mouse report over both other branches; the emulator
|
||||
// turns it into the wire bytes itself, so nothing reaches the key sink.
|
||||
assertTrue(sink.sent.isEmpty(), "a mouse-tracking program reads wheel reports, not cursor keys")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drag shorter than the touch slop is left to the stock view`() {
|
||||
bind(ENTER_ALTERNATE_BUFFER)
|
||||
|
||||
assertFalse(host.onInterceptTouchEvent(down(START_Y)))
|
||||
assertFalse(
|
||||
host.onInterceptTouchEvent(move(START_Y - 1f)),
|
||||
"a one-pixel wobble is a tap; stealing it would break tap-to-focus",
|
||||
)
|
||||
assertTrue(sink.sent.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is emitted before an emulator is bound`() {
|
||||
dragUp(pixels = 4 * CELL_HEIGHT_PX)
|
||||
|
||||
assertTrue(sink.sent.isEmpty())
|
||||
}
|
||||
|
||||
// ── Finding 2: autofill ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the terminal subtree is excluded from the autofill structure`() {
|
||||
// TerminalView.autofill() writes straight into a null mTermSession (StockTerminalViewHazardTest),
|
||||
// and it advertises itself as fillable, so the only fix is to keep it out of the structure the
|
||||
// autofill service walks. `View.isImportantForAutofill()` walks the parent chain calling
|
||||
// getImportantForAutofill() and stops at the first *_EXCLUDE_DESCENDANTS, so the frame answering
|
||||
// this covers the stock child — which is why the frame OVERRIDES the getter rather than only
|
||||
// setting the flag (a setter can be undone; an override cannot).
|
||||
assertEquals(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS, host.importantForAutofill)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Where a drag starts, far enough from 0 that a downward drag stays positive. */
|
||||
const val START_Y: Float = 400f
|
||||
|
||||
/** Comfortably past any plausible touch slop, so the claim is unambiguous. */
|
||||
const val CLAIM_TRAVEL_PX: Float = 100f
|
||||
|
||||
/** Stock `onGenericMotionEvent` scrolls three rows per wheel notch. */
|
||||
const val MOUSE_WHEEL_ROWS: Int = 3
|
||||
|
||||
/** Enough output to push rows off the top so there is a transcript to scroll into. */
|
||||
const val SCROLLED_OFF_LINES: Int = 40
|
||||
|
||||
/** More cells than the transcript holds, to prove the clamp. */
|
||||
const val OVERSCROLL_CELLS: Int = 500
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
|
||||
/**
|
||||
* BLOCKER 2, the session half: a resize is an emulator MUTATION, so §6.2's single-WRITER model requires
|
||||
* it to travel the same confined `Channel<TerminalCommand>` as the output feed — never a direct call
|
||||
* from the layout thread. These tests pin both the wire behaviour (one `Resize` per real change) and the
|
||||
* ordering (a resize submitted before a feed is applied to the buffer before that feed).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RemoteTerminalSessionResizeTest {
|
||||
|
||||
private fun sentResizes(sent: List<ClientMessage>) = sent.filterIsInstance<ClientMessage.Resize>()
|
||||
|
||||
@Test
|
||||
fun `updateSize reflows the emulator and emits one Resize frame`() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val sent = mutableListOf<ClientMessage>()
|
||||
val session = RemoteTerminalSession(
|
||||
engineSend = { sent += it },
|
||||
appendDispatcher = dispatcher,
|
||||
mainDispatcher = dispatcher,
|
||||
)
|
||||
session.bind {}
|
||||
|
||||
session.updateSize(cols = 100, rows = 40)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf(ClientMessage.Resize(100, 40)), sentResizes(sent))
|
||||
assertEquals(100, session.emulator.mColumns, "the local buffer must reflow to match the server")
|
||||
assertEquals(40, session.emulator.mRows)
|
||||
|
||||
session.close()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an identical resize is skipped so the socket is not spammed`() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val sent = mutableListOf<ClientMessage>()
|
||||
val session = RemoteTerminalSession(
|
||||
engineSend = { sent += it },
|
||||
appendDispatcher = dispatcher,
|
||||
mainDispatcher = dispatcher,
|
||||
)
|
||||
session.bind {}
|
||||
|
||||
session.updateSize(100, 40)
|
||||
session.updateSize(100, 40)
|
||||
session.updateSize(100, 40)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, sentResizes(sent).size)
|
||||
assertEquals(TerminalGridSize(100, 40), session.lastSentDimsForTest())
|
||||
|
||||
session.close()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-positive dimension never reaches the wire`() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val sent = mutableListOf<ClientMessage>()
|
||||
val session = RemoteTerminalSession(
|
||||
engineSend = { sent += it },
|
||||
appendDispatcher = dispatcher,
|
||||
mainDispatcher = dispatcher,
|
||||
)
|
||||
session.bind {}
|
||||
|
||||
session.updateSize(0, 40)
|
||||
session.updateSize(80, 0)
|
||||
session.updateSize(-5, -5)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(sentResizes(sent).isEmpty(), "server validates 1..1000; never send a bogus grid")
|
||||
|
||||
session.close()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a resize submitted before a feed is applied to the buffer first`() = runTest {
|
||||
// The ordering guarantee of the confined writer: if the resize were applied off-channel (or
|
||||
// after), these 7 characters would lay out against the OLD 80-column grid and the wrap point
|
||||
// would be wrong — exactly the class of bug the single-writer model exists to prevent.
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val session = RemoteTerminalSession(
|
||||
engineSend = {},
|
||||
appendDispatcher = dispatcher,
|
||||
mainDispatcher = dispatcher,
|
||||
)
|
||||
session.bind {}
|
||||
|
||||
session.updateSize(cols = 5, rows = 24)
|
||||
session.feedRemote("ABCDEFG".toByteArray(Charsets.UTF_8))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(5, session.emulator.mColumns)
|
||||
val text = session.screenTextForTest()
|
||||
assertTrue(text.contains("ABCDE"), "expected a wrap at column 5; got: '${text.trim()}'")
|
||||
assertTrue(text.contains("FG"), "the overflow must land on the next row; got: '${text.trim()}'")
|
||||
|
||||
session.close()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a resize repaints the view because the local buffer reflowed`() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val session = RemoteTerminalSession(
|
||||
engineSend = {},
|
||||
appendDispatcher = dispatcher,
|
||||
mainDispatcher = dispatcher,
|
||||
)
|
||||
var updates = 0
|
||||
session.bind { updates++ }
|
||||
advanceUntilIdle()
|
||||
val afterBind = updates
|
||||
|
||||
session.updateSize(100, 40)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(updates > afterBind, "a reflow that is never repainted leaves a stale screen")
|
||||
|
||||
session.close()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a resize before bind still reaches the wire and is applied in order`() = runTest {
|
||||
// The first layout pass can beat the AndroidView bind; the server must still learn the real grid.
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val sent = mutableListOf<ClientMessage>()
|
||||
val session = RemoteTerminalSession(
|
||||
engineSend = { sent += it },
|
||||
appendDispatcher = dispatcher,
|
||||
mainDispatcher = dispatcher,
|
||||
)
|
||||
|
||||
session.updateSize(5, 24)
|
||||
session.feedRemote("ABCDEFG".toByteArray(Charsets.UTF_8))
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(ClientMessage.Resize(5, 24)), sentResizes(sent))
|
||||
|
||||
session.bind {}
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(session.screenTextForTest().contains("ABCDE"))
|
||||
|
||||
session.close()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Paint
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
import com.termux.terminal.TerminalOutput
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkConstructor
|
||||
|
||||
/**
|
||||
* Headless fixtures for driving the **real** Termux `TerminalView` / [RemoteTerminalHostView] on the JVM.
|
||||
*
|
||||
* The AGP mockable `android.jar` (`isReturnDefaultValues = true`) strips every framework method body, so
|
||||
* the views construct and their own bytecode runs for real — which is exactly what these tests need: the
|
||||
* stock crash paths and our guards are the real compiled code, not a re-description of it. Two framework
|
||||
* pieces do need help:
|
||||
* - `Context.getSystemService("accessibility")` — the stock `TerminalView` constructor calls
|
||||
* `isEnabled()` on the result, so a default `null` would NPE before the view exists.
|
||||
* - `Paint` — `TerminalRenderer` measures its cell box through it, and default `0f` metrics would make
|
||||
* every cell zero-height. [mockCellMetrics] pins a [CELL_WIDTH_PX]×[CELL_HEIGHT_PX] cell so the
|
||||
* px→row arithmetic is exercised with real numbers.
|
||||
*/
|
||||
internal object StockTerminalViewFixture {
|
||||
|
||||
/** The pinned cell box these tests measure scroll distances against. */
|
||||
const val CELL_WIDTH_PX: Float = 12f
|
||||
const val CELL_HEIGHT_PX: Int = 24
|
||||
|
||||
/** Termux's `TerminalRenderer` derives line spacing from `Paint.getFontSpacing()`. */
|
||||
private const val FONT_ASCENT_PX: Float = -18f
|
||||
|
||||
/** A context complete enough for `TerminalView`'s constructor and our sp→px text sizing. */
|
||||
fun context(): Context {
|
||||
val resources = mockk<Resources>(relaxed = true)
|
||||
every { resources.displayMetrics } returns DisplayMetrics()
|
||||
val context = mockk<Context>(relaxed = true)
|
||||
every { context.resources } returns resources
|
||||
every { context.getSystemService("accessibility") } returns mockk<AccessibilityManager>(relaxed = true)
|
||||
return context
|
||||
}
|
||||
|
||||
/** Must be called BEFORE building a view — the renderer measures its cell in the constructor. */
|
||||
fun mockCellMetrics() {
|
||||
mockkConstructor(Paint::class)
|
||||
every { anyConstructed<Paint>().fontSpacing } returns CELL_HEIGHT_PX.toFloat()
|
||||
every { anyConstructed<Paint>().ascent() } returns FONT_ASCENT_PX
|
||||
every { anyConstructed<Paint>().measureText(any<String>()) } returns CELL_WIDTH_PX
|
||||
}
|
||||
|
||||
/** A real emulator with no subprocess — the same shape `RemoteTerminalSession` binds. */
|
||||
fun emulator(cols: Int = 80, rows: Int = 24, transcriptRows: Int = 1_000): TerminalEmulator =
|
||||
TerminalEmulator(SilentTerminalOutput(), cols, rows, transcriptRows, NoOpTerminalSessionClient())
|
||||
|
||||
/** Feed raw bytes the way the WS output path does. */
|
||||
fun TerminalEmulator.feed(text: String) {
|
||||
val bytes = text.toByteArray()
|
||||
append(bytes, bytes.size)
|
||||
}
|
||||
|
||||
/** `ESC[?1049h` — the alternate screen buffer every full-screen TUI (vim/htop/less/man) enters. */
|
||||
const val ENTER_ALTERNATE_BUFFER: String = "\u001b[?1049h"
|
||||
|
||||
/** `ESC[?1h` — DECCKM application-cursor mode, which switches arrows from CSI to SS3. */
|
||||
const val ENABLE_APPLICATION_CURSOR_KEYS: String = "\u001b[?1h"
|
||||
|
||||
/** `ESC[?1000h` — X11 mouse reporting, the branch stock `doScroll` prefers over everything else. */
|
||||
const val ENABLE_MOUSE_TRACKING: String = "\u001b[?1000h"
|
||||
|
||||
fun down(y: Float, x: Float = 0f): MotionEvent = touch(MotionEvent.ACTION_DOWN, x, y)
|
||||
fun move(y: Float, x: Float = 0f, pointers: Int = 1): MotionEvent =
|
||||
touch(MotionEvent.ACTION_MOVE, x, y, pointers)
|
||||
|
||||
fun up(y: Float, x: Float = 0f): MotionEvent = touch(MotionEvent.ACTION_UP, x, y)
|
||||
|
||||
private fun touch(action: Int, x: Float, y: Float, pointers: Int = 1): MotionEvent {
|
||||
val event = mockk<MotionEvent>(relaxed = true)
|
||||
every { event.action } returns action
|
||||
every { event.actionMasked } returns action
|
||||
every { event.x } returns x
|
||||
every { event.y } returns y
|
||||
every { event.pointerCount } returns pointers
|
||||
every { event.downTime } returns TOUCH_DOWN_TIME
|
||||
every { event.isFromSource(any()) } returns false
|
||||
return event
|
||||
}
|
||||
|
||||
/** An external-mouse wheel notch — the second stock entry into `doScroll`. */
|
||||
fun mouseWheel(scrollUp: Boolean): MotionEvent {
|
||||
val event = mockk<MotionEvent>(relaxed = true)
|
||||
every { event.action } returns MotionEvent.ACTION_SCROLL
|
||||
every { event.actionMasked } returns MotionEvent.ACTION_SCROLL
|
||||
every { event.isFromSource(InputDevice.SOURCE_MOUSE) } returns true
|
||||
every { event.getAxisValue(MotionEvent.AXIS_VSCROLL) } returns if (scrollUp) 1f else -1f
|
||||
every { event.x } returns 0f
|
||||
every { event.y } returns 0f
|
||||
every { event.downTime } returns TOUCH_DOWN_TIME
|
||||
return event
|
||||
}
|
||||
|
||||
/** One gesture == one down-time, which is what stock `sendMouseEventCode` anchors the wheel cell on. */
|
||||
private const val TOUCH_DOWN_TIME: Long = 1_000L
|
||||
|
||||
private class SilentTerminalOutput : TerminalOutput() {
|
||||
override fun write(data: ByteArray?, offset: Int, count: Int) = Unit
|
||||
override fun titleChanged(oldTitle: String?, newTitle: String?) = Unit
|
||||
override fun onCopyTextToClipboard(text: String?) = Unit
|
||||
override fun onPasteTextFromClipboard() = Unit
|
||||
override fun onBell() = Unit
|
||||
override fun onColorsChanged() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Captures everything the host view routes out, so a test can assert exact bytes. */
|
||||
internal class RecordingInputSink : TerminalInputSink {
|
||||
val sent = mutableListOf<String>()
|
||||
var taps = 0
|
||||
val sizes = mutableListOf<Pair<Int, Int>>()
|
||||
|
||||
override fun appConsumesKey(keyCode: Int, event: android.view.KeyEvent): Boolean = false
|
||||
override fun cursorModes(): TerminalCursorModes? = null
|
||||
override fun sendInput(data: String) {
|
||||
sent += data
|
||||
}
|
||||
|
||||
override fun onTapped() {
|
||||
taps++
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) {
|
||||
sizes += widthPx to heightPx
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.autofill.AutofillValue
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed
|
||||
import com.termux.view.TerminalView
|
||||
|
||||
/**
|
||||
* The two stock `TerminalView` methods that dereference `mTermSession` with **no null guard**, executed
|
||||
* for real so the hazard is a measured fact rather than a bytecode reading.
|
||||
*
|
||||
* `mTermSession` is null forever in this app: `TerminalSession` is `final`, its constructor forks a local
|
||||
* process over JNI, and having no local process is the whole point (plan §6.1). A dummy session does not
|
||||
* help either — `getEmulator()` would return null and NPE on the very next instruction.
|
||||
*
|
||||
* These tests do not change behaviour; they LOCK the reason [RemoteTerminalHostView] must keep both paths
|
||||
* unreachable. If a future Termux bump adds the missing guards, they fail — which is the signal to
|
||||
* re-audit and simplify, not to delete them.
|
||||
*/
|
||||
class StockTerminalViewHazardTest {
|
||||
|
||||
@AfterEach fun tearDown() = unmockkAll()
|
||||
|
||||
/**
|
||||
* `doScroll` offsets 56-79: while the ALTERNATE screen buffer is active it turns a scroll into
|
||||
* `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)`, whose offsets 15-19 are `mTermSession.getEmulator()`
|
||||
* with no guard. `mEmulator` IS guarded at offset 4; `mTermSession` is not — that asymmetry is the
|
||||
* whole bug. Any swipe inside vim/htop/less/man/tmux takes this path.
|
||||
*/
|
||||
@Test
|
||||
fun `stock scrolling on an alternate-screen buffer dereferences the absent TerminalSession`() {
|
||||
StockTerminalViewFixture.mockCellMetrics()
|
||||
val view = TerminalView(StockTerminalViewFixture.context(), null)
|
||||
view.setTextSize(TEXT_SIZE_PX)
|
||||
val emulator = StockTerminalViewFixture.emulator()
|
||||
emulator.feed(ENTER_ALTERNATE_BUFFER)
|
||||
view.mEmulator = emulator
|
||||
|
||||
assertTrue(emulator.isAlternateBufferActive, "the fixture must reproduce the crashing state")
|
||||
assertThrows(NullPointerException::class.java) {
|
||||
view.handleKeyCode(KeyEvent.KEYCODE_DPAD_UP, NO_KEY_MODIFIERS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `autofill(AutofillValue)` offsets 7-20: `mTermSession.write(value.getTextValue().toString())`, again
|
||||
* unguarded — and the view advertises itself as fillable (`getAutofillType()` = AUTOFILL_TYPE_TEXT,
|
||||
* `getAutofillValue()` non-null), so any autofill service may call it. [RemoteTerminalHostView]
|
||||
* answers by excluding the whole subtree from the autofill structure.
|
||||
*/
|
||||
@Test
|
||||
fun `stock autofill dereferences the absent TerminalSession`() {
|
||||
StockTerminalViewFixture.mockCellMetrics()
|
||||
val view = TerminalView(StockTerminalViewFixture.context(), null)
|
||||
val filled = mockk<AutofillValue>()
|
||||
every { filled.isText } returns true
|
||||
every { filled.textValue } returns "a-password-manager-suggestion"
|
||||
|
||||
assertThrows(NullPointerException::class.java) { view.autofill(filled) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NO_KEY_MODIFIERS: Int = 0
|
||||
const val TEXT_SIZE_PX: Int = 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import com.termux.terminal.KeyHandler
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* [TerminalInputBytes] is the byte-for-byte re-derivation of the two Termux paths our client has to
|
||||
* take over, because both of them dead-end on the `TerminalSession` we do not have:
|
||||
*
|
||||
* - `TerminalView.inputCodePoint(codePoint, ctrl, alt)` — the control-key + dead-key transform
|
||||
* (verified against `terminal-view-v0.118.0.aar` → `TerminalView.class` offsets 148–374), which then
|
||||
* calls `mTermSession.writeCodePoint(...)`. `inputCodePoint` early-returns at offset 59–66 when
|
||||
* `mTermSession == null`, so with our forked session the whole IME text path is silently DROPPED.
|
||||
* - `TerminalSession.writeCodePoint(prependEscape, codePoint)` (offsets 0–125) — an optional `ESC`
|
||||
* prefix for Alt, UTF-8 encoding, and an `IllegalArgumentException` for an invalid code point (we
|
||||
* return null instead — a hostile/garbled key must never throw into the key path).
|
||||
*
|
||||
* These assertions are the regression lock: if the transform drifts, Ctrl-C stops interrupting Claude.
|
||||
*/
|
||||
class TerminalInputBytesTest {
|
||||
|
||||
@Test
|
||||
fun `control transform maps letters to their C0 codes`() {
|
||||
// Ctrl-C is THE key of this app (interrupt Claude): 'c' (99) -> 0x03.
|
||||
assertEquals("\u0003", TerminalInputBytes.encodeCodePoint('c'.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u0003", TerminalInputBytes.encodeCodePoint('C'.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u0001", TerminalInputBytes.encodeCodePoint('a'.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u001a", TerminalInputBytes.encodeCodePoint('z'.code, ctrlDown = true, altDown = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `control transform maps the punctuation and digit aliases`() {
|
||||
// Offsets 197-318 of TerminalView.inputCodePoint, alias-for-alias.
|
||||
assertEquals("\u0000", TerminalInputBytes.encodeCodePoint(' '.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u0000", TerminalInputBytes.encodeCodePoint('2'.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u001b", TerminalInputBytes.encodeCodePoint('['.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u001b", TerminalInputBytes.encodeCodePoint('3'.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u001c", TerminalInputBytes.encodeCodePoint('\\'.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u001d", TerminalInputBytes.encodeCodePoint(']'.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u001e", TerminalInputBytes.encodeCodePoint('^'.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u001f", TerminalInputBytes.encodeCodePoint('_'.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u001f", TerminalInputBytes.encodeCodePoint('/'.code, ctrlDown = true, altDown = false))
|
||||
assertEquals("\u007f", TerminalInputBytes.encodeCodePoint('8'.code, ctrlDown = true, altDown = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmapped code point is unchanged by the control transform`() {
|
||||
assertEquals("!", TerminalInputBytes.encodeCodePoint('!'.code, ctrlDown = true, altDown = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alt prepends ESC, matching writeCodePoint's prependEscape`() {
|
||||
assertEquals("\u001bx", TerminalInputBytes.encodeCodePoint('x'.code, ctrlDown = false, altDown = true))
|
||||
// Alt+Ctrl composes: ESC then the C0 code.
|
||||
assertEquals("\u001b\u0003", TerminalInputBytes.encodeCodePoint('c'.code, ctrlDown = true, altDown = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dead-key aliases map to their ASCII equivalents after the control transform`() {
|
||||
// TerminalView.inputCodePoint offsets 319-374: 710 -> '^', 715 -> '`', 732 -> '~'.
|
||||
assertEquals("^", TerminalInputBytes.encodeCodePoint(710, ctrlDown = false, altDown = false))
|
||||
assertEquals("`", TerminalInputBytes.encodeCodePoint(715, ctrlDown = false, altDown = false))
|
||||
assertEquals("~", TerminalInputBytes.encodeCodePoint(732, ctrlDown = false, altDown = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an invalid code point yields null instead of throwing`() {
|
||||
// TerminalSession.writeCodePoint THROWS IllegalArgumentException here (offsets 0-44). A garbled
|
||||
// key event must never take down the key path, so we drop it.
|
||||
assertNull(TerminalInputBytes.encodeCodePoint(0x110000, ctrlDown = false, altDown = false))
|
||||
assertNull(TerminalInputBytes.encodeCodePoint(0xD800, ctrlDown = false, altDown = false))
|
||||
assertNull(TerminalInputBytes.encodeCodePoint(0xDFFF, ctrlDown = false, altDown = false))
|
||||
assertNull(TerminalInputBytes.encodeCodePoint(-1, ctrlDown = false, altDown = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-BMP code point round-trips as a surrogate pair`() {
|
||||
val emoji = 0x1F600
|
||||
assertEquals(String(Character.toChars(emoji)), TerminalInputBytes.encodeCodePoint(emoji, false, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encodeText maps LF to CR and preserves everything else`() {
|
||||
// The repo-wide gotcha: Enter is \r (0x0D), never \n. Termux's sendTextToTerminal does the same
|
||||
// remap (TerminalView$2.sendTextToTerminal offsets 113-122) before inputCodePoint.
|
||||
assertEquals("ls -la\r", TerminalInputBytes.encodeText("ls -la\n"))
|
||||
assertEquals("hi\r", TerminalInputBytes.encodeText("hi\r"))
|
||||
assertEquals("", TerminalInputBytes.encodeText(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encodeText preserves CJK and surrogate pairs verbatim`() {
|
||||
// Invariant #9/#1: input bytes pass through unfiltered — an IME commit of CJK or an emoji must
|
||||
// arrive at the shell byte-identical.
|
||||
assertEquals("你好", TerminalInputBytes.encodeText("你好"))
|
||||
val emoji = String(Character.toChars(0x1F600))
|
||||
assertEquals(emoji, TerminalInputBytes.encodeText(emoji))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keyModifiers builds the Termux KEYMOD bitset`() {
|
||||
assertEquals(0, TerminalInputBytes.keyModifiers(false, false, false, false))
|
||||
assertEquals(KeyHandler.KEYMOD_CTRL, TerminalInputBytes.keyModifiers(true, false, false, false))
|
||||
assertEquals(KeyHandler.KEYMOD_ALT, TerminalInputBytes.keyModifiers(false, true, false, false))
|
||||
assertEquals(KeyHandler.KEYMOD_SHIFT, TerminalInputBytes.keyModifiers(false, false, true, false))
|
||||
assertEquals(KeyHandler.KEYMOD_NUM_LOCK, TerminalInputBytes.keyModifiers(false, false, false, true))
|
||||
assertEquals(
|
||||
KeyHandler.KEYMOD_CTRL or KeyHandler.KEYMOD_SHIFT,
|
||||
TerminalInputBytes.keyModifiers(ctrl = true, alt = false, shift = true, numLock = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `effectiveMetaState clears the ctrl bits and re-adds shift`() {
|
||||
// TerminalView.onKeyDown offsets 357-418: bitsToClear = META_CTRL_MASK (+ the ALT bits unless
|
||||
// NumLock), then shift re-adds META_SHIFT_ON|META_SHIFT_LEFT_ON (65).
|
||||
val ctrlAndAlt = TerminalInputBytes.META_CTRL_MASK or TerminalInputBytes.META_ALT_BITS
|
||||
assertEquals(0, TerminalInputBytes.effectiveMetaState(ctrlAndAlt, shift = false, numLockOn = false))
|
||||
assertEquals(
|
||||
TerminalInputBytes.META_ALT_BITS,
|
||||
TerminalInputBytes.effectiveMetaState(ctrlAndAlt, shift = false, numLockOn = true),
|
||||
)
|
||||
assertEquals(
|
||||
TerminalInputBytes.META_SHIFT_BITS,
|
||||
TerminalInputBytes.effectiveMetaState(ctrlAndAlt, shift = true, numLockOn = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyEvent
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* BLOCKER 1 — the decision table that keeps every stock Termux path that dereferences `mTermSession`
|
||||
* UNREACHABLE.
|
||||
*
|
||||
* Verified against `terminal-view-v0.118.0.aar` → `TerminalView.class`:
|
||||
* - `onKeyDown` offset 82–92 calls `mClient.onKeyDown(keyCode, event, mTermSession)` with **no null
|
||||
* guard on `mClient`** → today's guaranteed NPE on the first key press. Offsets 97–105 show the only
|
||||
* escape hatch: a client that returns **true** ends the method at `invalidate(); return true`.
|
||||
* - Every path past that point touches the session we do not have: offset 149–160
|
||||
* `mTermSession.write(event.getCharacters())` (ACTION_MULTIPLE), offset 326 → `handleKeyCode` whose
|
||||
* offsets 15–19 do `mTermSession.getEmulator()` with no guard, and offset 558 → `inputCodePoint`
|
||||
* which dead-ends at its own `mTermSession == null` early return (offset 59–66).
|
||||
*
|
||||
* So [KeyRouting] deliberately has **no variant that hands the key back to the stock terminal path**:
|
||||
* the only non-consuming outcome is [KeyRouting.DeferToHostView], which we implement as
|
||||
* `FrameLayout.super.onKeyDown` — the Activity's BACK/volume handling, never Termux's.
|
||||
*/
|
||||
class TerminalKeyDecoderTest {
|
||||
|
||||
/** Facts with everything off — the shape of a plain, unmodified key press. */
|
||||
private fun facts(
|
||||
isSystemKey: Boolean = false,
|
||||
ctrl: Boolean = false,
|
||||
alt: Boolean = false,
|
||||
shift: Boolean = false,
|
||||
numLock: Boolean = false,
|
||||
function: Boolean = false,
|
||||
metaState: Int = 0,
|
||||
unicodeChar: (Int) -> Int = { 0 },
|
||||
) = KeyEventFacts(isSystemKey, ctrl, alt, shift, numLock, function, metaState, unicodeChar)
|
||||
|
||||
// ── The mTermSession-unreachability invariant ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `KeyRouting has no branch that defers to the stock terminal path`() {
|
||||
// A structural lock on the design constraint. The `when` below is EXHAUSTIVE over the sealed
|
||||
// hierarchy, so adding a fourth outcome — notably a "let Termux handle it" one, which would put
|
||||
// mTermSession back on the reachable path — fails compilation here and cannot land unnoticed.
|
||||
val outcomes = listOf(KeyRouting.Send("x"), KeyRouting.Consumed, KeyRouting.DeferToHostView)
|
||||
val labels = outcomes.map { routing ->
|
||||
when (routing) {
|
||||
is KeyRouting.Send -> "bytes onto the wire"
|
||||
KeyRouting.Consumed -> "swallowed"
|
||||
KeyRouting.DeferToHostView -> "host view super (Activity BACK/volume)"
|
||||
}
|
||||
}
|
||||
assertEquals(3, labels.toSet().size, "the three outcomes must stay distinct")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmapped, unprintable key is consumed rather than handed back to Termux`() {
|
||||
// e.g. a bare CTRL_LEFT: KeyHandler has no code and getUnicodeChar is 0. Stock would return false
|
||||
// and fall through toward mTermSession; we consume instead.
|
||||
val routing = TerminalKeyDecoder.decodeKeyDown(
|
||||
keyCode = KeyEvent.KEYCODE_CTRL_LEFT,
|
||||
facts = facts(),
|
||||
appConsumed = false,
|
||||
cursorKeysApplication = false,
|
||||
keypadApplication = false,
|
||||
)
|
||||
assertEquals(KeyRouting.Consumed, routing)
|
||||
}
|
||||
|
||||
// ── DECCKM (plan §6.3) — the authority stays Termux's KeyHandler ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `arrows resolve through KeyHandler and differ between normal and application-cursor mode`() {
|
||||
val normal = TerminalKeyDecoder.decodeKeyDown(
|
||||
KeyEvent.KEYCODE_DPAD_UP, facts(), appConsumed = false,
|
||||
cursorKeysApplication = false, keypadApplication = false,
|
||||
)
|
||||
val application = TerminalKeyDecoder.decodeKeyDown(
|
||||
KeyEvent.KEYCODE_DPAD_UP, facts(), appConsumed = false,
|
||||
cursorKeysApplication = true, keypadApplication = false,
|
||||
)
|
||||
assertEquals(KeyRouting.Send("\u001b[A"), normal, "DECCKM off must emit CSI")
|
||||
assertEquals(KeyRouting.Send("\u001bOA"), application, "DECCKM on must emit SS3, never a hardcoded CSI")
|
||||
assertTrue(normal != application, "the two cursor modes must not collapse to one byte string")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Enter emits CR and Tab emits HT, shift-Tab emits CSI Z`() {
|
||||
assertEquals(
|
||||
KeyRouting.Send("\r"),
|
||||
TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_ENTER, facts(), false, false, false),
|
||||
"Enter must send \\r (0x0D), never \\n",
|
||||
)
|
||||
assertEquals(
|
||||
KeyRouting.Send("\t"),
|
||||
TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_TAB, facts(), false, false, false),
|
||||
)
|
||||
assertEquals(
|
||||
KeyRouting.Send("\u001b[Z"),
|
||||
TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_TAB, facts(shift = true), false, false, false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Backspace emits DEL`() {
|
||||
assertEquals(
|
||||
KeyRouting.Send("\u007f"),
|
||||
TerminalKeyDecoder.decodeKeyDown(KeyEvent.KEYCODE_DEL, facts(), false, false, false),
|
||||
)
|
||||
}
|
||||
|
||||
// ── The app outlet (:app's HardwareKeyRouter via RemoteTerminalView.onKeyCommand) ────────────────
|
||||
|
||||
@Test
|
||||
fun `an app-consumed key emits nothing more but is still consumed`() {
|
||||
// :app already sent the bytes through onKeyCommand; emitting again would double-type.
|
||||
val routing = TerminalKeyDecoder.decodeKeyDown(
|
||||
KeyEvent.KEYCODE_ESCAPE, facts(), appConsumed = true,
|
||||
cursorKeysApplication = false, keypadApplication = false,
|
||||
)
|
||||
assertEquals(KeyRouting.Consumed, routing)
|
||||
}
|
||||
|
||||
// ── System keys keep working (BACK must still leave the screen) ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a system key defers to the host view so BACK still navigates`() {
|
||||
val routing = TerminalKeyDecoder.decodeKeyDown(
|
||||
KeyEvent.KEYCODE_BACK, facts(isSystemKey = true), appConsumed = false,
|
||||
cursorKeysApplication = false, keypadApplication = false,
|
||||
)
|
||||
assertEquals(KeyRouting.DeferToHostView, routing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `key-up defers only for system keys and consumes everything else`() {
|
||||
assertEquals(KeyRouting.DeferToHostView, TerminalKeyDecoder.decodeKeyUp(facts(isSystemKey = true)))
|
||||
assertEquals(KeyRouting.Consumed, TerminalKeyDecoder.decodeKeyUp(facts()))
|
||||
}
|
||||
|
||||
// ── Printable keys: the unicode path replaces the dead inputCodePoint path ───────────────────────
|
||||
|
||||
@Test
|
||||
fun `a printable key with no KeyHandler mapping sends its unicode char`() {
|
||||
val routing = TerminalKeyDecoder.decodeKeyDown(
|
||||
KeyEvent.KEYCODE_A, facts(unicodeChar = { 'a'.code }), appConsumed = false,
|
||||
cursorKeysApplication = false, keypadApplication = false,
|
||||
)
|
||||
assertEquals(KeyRouting.Send("a"), routing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Ctrl-C resolves to the interrupt byte`() {
|
||||
val routing = TerminalKeyDecoder.decodeKeyDown(
|
||||
KeyEvent.KEYCODE_C, facts(ctrl = true, unicodeChar = { 'c'.code }), appConsumed = false,
|
||||
cursorKeysApplication = false, keypadApplication = false,
|
||||
)
|
||||
assertEquals(KeyRouting.Send("\u0003"), routing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Alt-x resolves to ESC + x`() {
|
||||
val routing = TerminalKeyDecoder.decodeKeyDown(
|
||||
KeyEvent.KEYCODE_X, facts(alt = true, unicodeChar = { 'x'.code }), appConsumed = false,
|
||||
cursorKeysApplication = false, keypadApplication = false,
|
||||
)
|
||||
assertEquals(KeyRouting.Send("\u001bx"), routing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dead-key combining accent is swallowed rather than emitting garbage`() {
|
||||
// getUnicodeChar sets KeyCharacterMap.COMBINING_ACCENT (0x80000000) for a dead key. Termux runs a
|
||||
// mCombiningAccent state machine on a package-private view field we cannot reach; we consume the
|
||||
// key instead so no garbled byte reaches the shell (composition is device-QA / IME-path work).
|
||||
val routing = TerminalKeyDecoder.decodeKeyDown(
|
||||
KeyEvent.KEYCODE_GRAVE,
|
||||
facts(unicodeChar = { TerminalInputBytes.COMBINING_ACCENT or '`'.code }),
|
||||
appConsumed = false, cursorKeysApplication = false, keypadApplication = false,
|
||||
)
|
||||
assertEquals(KeyRouting.Consumed, routing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a function-pressed key skips the KeyHandler table like Termux does`() {
|
||||
// TerminalView.onKeyDown offset 319-333 gates handleKeyCode on !event.isFunctionPressed().
|
||||
val routing = TerminalKeyDecoder.decodeKeyDown(
|
||||
KeyEvent.KEYCODE_DPAD_UP, facts(function = true, unicodeChar = { 0 }), appConsumed = false,
|
||||
cursorKeysApplication = false, keypadApplication = false,
|
||||
)
|
||||
assertEquals(KeyRouting.Consumed, routing, "Fn+Up must not emit the plain arrow sequence")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* BLOCKER 2 — nothing ever drove a `resize`, so the server PTY stayed at its 80×24 attach default
|
||||
* forever and every full-screen TUI (Claude's own UI, vim, htop) rendered into the wrong box.
|
||||
*
|
||||
* The stock fallback is dead by construction: `TerminalView.updateSize()` (verified in
|
||||
* `terminal-view-v0.118.0.aar`, offsets 18–25) early-returns when `mTermSession == null`, which is
|
||||
* permanently our case. [TerminalResizeDriver] is the replacement: it turns a real layout size + the
|
||||
* renderer's real cell metrics into a grid via the already-tested pure [TerminalGridMath], and pushes it
|
||||
* at the session (which routes it through the confined writer — see [RemoteTerminalSessionResizeTest]).
|
||||
*/
|
||||
class TerminalResizeDriverTest {
|
||||
|
||||
/** Metrics in the shape `TerminalRenderer` reports them: a fractional advance width, an int line box. */
|
||||
private val metrics = TerminalCellMetrics(fontWidthPx = 10f, lineSpacingPx = 20)
|
||||
|
||||
/** One push at the driver's outlet: the grid, and whether it was flagged as a forced re-assert. */
|
||||
private data class Push(val grid: TerminalGridSize, val isReassert: Boolean)
|
||||
|
||||
private class Recorder {
|
||||
val pushes = mutableListOf<Push>()
|
||||
val grids: List<TerminalGridSize> get() = pushes.map { it.grid }
|
||||
fun apply(grid: TerminalGridSize, isReassert: Boolean) {
|
||||
pushes += Push(grid, isReassert)
|
||||
}
|
||||
}
|
||||
|
||||
private fun driver(recorder: Recorder, metrics: () -> TerminalCellMetrics? = { this.metrics }) =
|
||||
TerminalResizeDriver(cellMetrics = metrics, applyGrid = recorder::apply)
|
||||
|
||||
@Test
|
||||
fun `a laid-out size resolves to the expected grid`() {
|
||||
val recorder = Recorder()
|
||||
driver(recorder).onViewSize(widthPx = 800, heightPx = 600, horizontalPaddingPx = 0, verticalPaddingPx = 0)
|
||||
|
||||
assertEquals(listOf(TerminalGridSize(cols = 80, rows = 30)), recorder.grids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `padding is subtracted on both edges`() {
|
||||
val recorder = Recorder()
|
||||
driver(recorder).onViewSize(800, 600, horizontalPaddingPx = 15, verticalPaddingPx = 10)
|
||||
|
||||
// (800 - 30)/10 = 77 cols · (600 - 20)/20 = 29 rows
|
||||
assertEquals(listOf(TerminalGridSize(cols = 77, rows = 29)), recorder.grids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a repeated identical size is skipped so rotation does not spam the socket`() {
|
||||
val recorder = Recorder()
|
||||
val driver = driver(recorder)
|
||||
|
||||
driver.onViewSize(800, 600, 0, 0)
|
||||
driver.onViewSize(800, 600, 0, 0)
|
||||
driver.onViewSize(800, 600, 0, 0)
|
||||
|
||||
assertEquals(1, recorder.grids.size, "an unchanged grid must not be pushed again")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different pixel size that resolves to the same grid is also skipped`() {
|
||||
val recorder = Recorder()
|
||||
val driver = driver(recorder)
|
||||
|
||||
driver.onViewSize(800, 600, 0, 0) // 80x30
|
||||
driver.onViewSize(805, 605, 0, 0) // still 80x30 — sub-cell growth, no new grid
|
||||
|
||||
assertEquals(1, recorder.grids.size, "dedup is on the GRID, not on the pixel size")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a genuinely new grid is pushed`() {
|
||||
val recorder = Recorder()
|
||||
val driver = driver(recorder)
|
||||
|
||||
driver.onViewSize(800, 600, 0, 0)
|
||||
driver.onViewSize(600, 800, 0, 0) // rotation
|
||||
|
||||
assertEquals(
|
||||
listOf(TerminalGridSize(80, 30), TerminalGridSize(60, 40)),
|
||||
recorder.grids,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pre-layout size is dropped`() {
|
||||
val recorder = Recorder()
|
||||
val driver = driver(recorder)
|
||||
|
||||
driver.onViewSize(0, 0, 0, 0)
|
||||
driver.onViewSize(800, 0, 0, 0)
|
||||
driver.onViewSize(0, 600, 0, 0)
|
||||
|
||||
assertTrue(recorder.grids.isEmpty(), "a 0-dimension view has no valid grid to send")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no metrics yet means no resize`() {
|
||||
// The renderer only exists after setTextSize; before that there is nothing to divide by.
|
||||
val recorder = Recorder()
|
||||
driver(recorder, metrics = { null }).onViewSize(800, 600, 0, 0)
|
||||
|
||||
assertTrue(recorder.grids.isEmpty(), "a missing renderer must not produce a bogus grid")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `degenerate metrics are dropped rather than producing a divide-by-zero grid`() {
|
||||
val recorder = Recorder()
|
||||
driver(recorder, metrics = { TerminalCellMetrics(0f, 0) }).onViewSize(800, 600, 0, 0)
|
||||
|
||||
assertTrue(recorder.grids.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a normal layout push is not a re-assert`() {
|
||||
val recorder = Recorder()
|
||||
driver(recorder).onViewSize(800, 600, 0, 0)
|
||||
|
||||
assertEquals(listOf(Push(TerminalGridSize(80, 30), isReassert = false)), recorder.pushes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-asserting re-pushes the measured grid and flags it as forced`() {
|
||||
// §6.4 latest-writer-wins: on device switch / pane-show the device that comes back must re-assert
|
||||
// its dims to win the shared PTY size back. The re-push must be FLAGGED, because the writer
|
||||
// downstream dedups on its own `lastSentDims` — an unflagged identical grid dies there and the
|
||||
// reclaim never reaches the wire (the exact double-dedup a review caught).
|
||||
val recorder = Recorder()
|
||||
val driver = driver(recorder)
|
||||
|
||||
driver.onViewSize(800, 600, 0, 0)
|
||||
driver.reassertLastGrid()
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
Push(TerminalGridSize(80, 30), isReassert = false),
|
||||
Push(TerminalGridSize(80, 30), isReassert = true),
|
||||
),
|
||||
recorder.pushes,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-asserting does not re-measure, so it works before the next layout pass`() {
|
||||
// The reclaim fires from a lifecycle/focus callback, NOT from onSizeChanged: there is no new
|
||||
// layout pass to piggyback on, so the driver must push from its own memo.
|
||||
val recorder = Recorder()
|
||||
val driver = driver(recorder)
|
||||
|
||||
driver.onViewSize(800, 600, 0, 0)
|
||||
driver.reassertLastGrid()
|
||||
driver.reassertLastGrid()
|
||||
|
||||
assertEquals(3, recorder.pushes.size)
|
||||
assertTrue(recorder.pushes.drop(1).all { it.isReassert })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-asserting before anything was measured pushes nothing`() {
|
||||
val recorder = Recorder()
|
||||
|
||||
driver(recorder).reassertLastGrid()
|
||||
|
||||
assertTrue(recorder.pushes.isEmpty(), "there is no grid to re-assert yet; the first layout will push")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a re-assert leaves the memo intact so the next identical layout is still deduped`() {
|
||||
val recorder = Recorder()
|
||||
val driver = driver(recorder)
|
||||
|
||||
driver.onViewSize(800, 600, 0, 0)
|
||||
driver.reassertLastGrid()
|
||||
driver.onViewSize(800, 600, 0, 0)
|
||||
|
||||
assertEquals(2, recorder.pushes.size, "layout churn must not ride along on the reclaim")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import com.termux.terminal.TerminalEmulator
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_APPLICATION_CURSOR_KEYS
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENABLE_MOUSE_TRACKING
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.ENTER_ALTERNATE_BUFFER
|
||||
import wang.yaojia.webterm.terminalview.StockTerminalViewFixture.feed
|
||||
|
||||
/**
|
||||
* The scroll arithmetic and branch selection, on their own — no view, no `MotionEvent`.
|
||||
*
|
||||
* The emulator here is the REAL Termux one driven by REAL escape sequences, so "alternate buffer" and
|
||||
* "application cursor keys" are the same state the crashing stock branch tests, not a boolean we made up.
|
||||
*/
|
||||
class TerminalScrollGestureTest {
|
||||
|
||||
private class FakeTarget(private val emulator: TerminalEmulator?) : TerminalScrollTarget {
|
||||
val keys = mutableListOf<String>()
|
||||
val wheelReports = mutableListOf<Boolean>()
|
||||
var transcriptSteps = 0
|
||||
|
||||
override fun emulator(): TerminalEmulator? = emulator
|
||||
override fun scrollTranscript(up: Boolean) {
|
||||
transcriptSteps += if (up) -1 else 1
|
||||
}
|
||||
|
||||
override fun sendKeys(data: String) {
|
||||
keys += data
|
||||
}
|
||||
|
||||
override fun reportMouseWheel(up: Boolean) {
|
||||
wheelReports += up
|
||||
}
|
||||
}
|
||||
|
||||
private fun gesture(target: FakeTarget, cellHeightPx: Int = CELL_HEIGHT_PX) =
|
||||
TerminalScrollGesture(TOUCH_SLOP_PX, { cellHeightPx }, target)
|
||||
|
||||
private fun emulatorWith(vararg sequences: String): TerminalEmulator =
|
||||
StockTerminalViewFixture.emulator().also { emulator -> sequences.forEach { emulator.feed(it) } }
|
||||
|
||||
@Test
|
||||
fun `the drag is not claimed until it passes the touch slop`() {
|
||||
val target = FakeTarget(emulatorWith())
|
||||
val scroll = gesture(target)
|
||||
|
||||
assertFalse(scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1)))
|
||||
assertFalse(
|
||||
scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - (TOUCH_SLOP_PX - 1), pointerCount = 1)),
|
||||
"a sub-slop wobble is a tap, and taps must stay with the stock view",
|
||||
)
|
||||
assertTrue(scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - TOUCH_SLOP_PX * 4, pointerCount = 1)))
|
||||
assertTrue(scroll.isOwned)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the slop travel is spent on the claim and does not scroll a row`() {
|
||||
val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
|
||||
val scroll = gesture(target)
|
||||
|
||||
scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1))
|
||||
// A claim that jumps a whole cell would make every drag start with a phantom keystroke.
|
||||
scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - 5 * CELL_HEIGHT_PX, pointerCount = 1))
|
||||
|
||||
assertTrue(target.keys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alternate-screen buffer turns rows into DECCKM-correct arrows`() {
|
||||
val csi = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
|
||||
gesture(csi).apply { dragUp(cells = 2) }
|
||||
|
||||
val ss3 = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER, ENABLE_APPLICATION_CURSOR_KEYS))
|
||||
gesture(ss3).apply { dragUp(cells = 2) }
|
||||
|
||||
assertEquals(listOf("\u001b[B", "\u001b[B"), csi.keys)
|
||||
assertEquals(listOf("\u001bOB", "\u001bOB"), ss3.keys, "application-cursor mode must switch form")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mouse tracking outranks the alternate buffer, exactly like stock doScroll`() {
|
||||
val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER, ENABLE_MOUSE_TRACKING))
|
||||
gesture(target).apply { dragUp(cells = 2) }
|
||||
|
||||
assertEquals(listOf(false, false), target.wheelReports, "two wheel-down reports, no cursor keys")
|
||||
assertTrue(target.keys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the normal buffer scrolls the transcript and puts nothing on the wire`() {
|
||||
val target = FakeTarget(emulatorWith())
|
||||
gesture(target).apply { dragUp(cells = 3) }
|
||||
|
||||
assertEquals(3, target.transcriptSteps, "dragging up walks toward the newest row")
|
||||
assertTrue(target.keys.isEmpty())
|
||||
assertTrue(target.wheelReports.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sub-cell travel accumulates instead of being lost`() {
|
||||
val target = FakeTarget(emulatorWith())
|
||||
val scroll = gesture(target)
|
||||
scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1))
|
||||
scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - CLAIM_TRAVEL_PX, pointerCount = 1))
|
||||
|
||||
// Three moves of a third of a cell each == exactly one row, not zero.
|
||||
var y = 500f - CLAIM_TRAVEL_PX
|
||||
repeat(3) {
|
||||
y -= CELL_HEIGHT_PX / 3f
|
||||
scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = y, pointerCount = 1))
|
||||
}
|
||||
|
||||
assertEquals(1, target.transcriptSteps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second finger leaves the gesture to the stock pinch detector`() {
|
||||
val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
|
||||
val scroll = gesture(target)
|
||||
|
||||
scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1))
|
||||
assertFalse(scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 100f, pointerCount = 2)))
|
||||
assertTrue(target.keys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wheel notch scrolls three rows, the stock amount`() {
|
||||
val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
|
||||
|
||||
gesture(target).onMouseWheel(scrollUp = true)
|
||||
|
||||
assertEquals(List(TerminalScrollGesture.MOUSE_WHEEL_ROWS) { "\u001b[A" }, target.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmeasured cell keeps the gesture but scrolls nothing`() {
|
||||
val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
|
||||
val scroll = gesture(target, cellHeightPx = 0)
|
||||
|
||||
scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1))
|
||||
scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 500f - CLAIM_TRAVEL_PX, pointerCount = 1))
|
||||
|
||||
// Handing the drag back mid-gesture would put the stock scroll path in play again.
|
||||
assertTrue(scroll.onTouch(TouchFacts(TouchPhase.MOVE, y = 0f, pointerCount = 1)))
|
||||
assertTrue(target.keys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is emitted while no emulator is bound`() {
|
||||
val target = FakeTarget(null)
|
||||
gesture(target).apply { dragUp(cells = 4) }
|
||||
|
||||
assertTrue(target.keys.isEmpty())
|
||||
assertEquals(0, target.transcriptSteps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the gesture ends and the next drag has to re-earn its claim`() {
|
||||
val target = FakeTarget(emulatorWith(ENTER_ALTERNATE_BUFFER))
|
||||
val scroll = gesture(target)
|
||||
scroll.dragUp(cells = 1)
|
||||
|
||||
assertFalse(scroll.isOwned, "an ended gesture must not leak ownership into the next tap")
|
||||
assertFalse(scroll.onTouch(TouchFacts(TouchPhase.DOWN, y = 500f, pointerCount = 1)))
|
||||
}
|
||||
|
||||
/** Claim the drag, then travel [cells] whole cells upward, then lift. */
|
||||
private fun TerminalScrollGesture.dragUp(cells: Int) {
|
||||
onTouch(TouchFacts(TouchPhase.DOWN, y = START_Y, pointerCount = 1))
|
||||
onTouch(TouchFacts(TouchPhase.MOVE, y = START_Y - CLAIM_TRAVEL_PX, pointerCount = 1))
|
||||
val end = START_Y - CLAIM_TRAVEL_PX - cells * CELL_HEIGHT_PX
|
||||
onTouch(TouchFacts(TouchPhase.MOVE, y = end, pointerCount = 1))
|
||||
onTouch(TouchFacts(TouchPhase.END, y = end, pointerCount = 1))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOUCH_SLOP_PX: Int = 8
|
||||
const val CELL_HEIGHT_PX: Int = 24
|
||||
const val START_Y: Float = 800f
|
||||
const val CLAIM_TRAVEL_PX: Float = 100f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package wang.yaojia.webterm.terminalview
|
||||
|
||||
import android.view.KeyEvent
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The client end-to-end: a key press arrives the way Termux delivers it — with a **null**
|
||||
* `TerminalSession`, exactly as `TerminalView.onKeyDown` passes its own null `mTermSession` at offset
|
||||
* 88–92 — and must come out as bytes on the sink, with `true` returned so the stock method stops at
|
||||
* `invalidate(); return true` (offset 97–105) instead of walking on into the session it does not have.
|
||||
*
|
||||
* `KeyEvent` here is the AGP mockable stub (`isReturnDefaultValues = true`), so every accessor reports
|
||||
* "unmodified, non-system, no keymap character" — which is precisely the shape of a bare arrow press,
|
||||
* the case that matters for DECCKM.
|
||||
*/
|
||||
class WebTermTerminalViewClientTest {
|
||||
|
||||
private class FakeSink(
|
||||
private val appHandles: Boolean = false,
|
||||
private var modes: TerminalCursorModes? = TerminalCursorModes(false, false),
|
||||
) : TerminalInputSink {
|
||||
val sent = mutableListOf<String>()
|
||||
var taps = 0
|
||||
var sizes = mutableListOf<Pair<Int, Int>>()
|
||||
var keysOfferedToApp = 0
|
||||
|
||||
fun setCursorApplicationMode(on: Boolean) {
|
||||
modes = TerminalCursorModes(cursorKeysApplication = on, keypadApplication = false)
|
||||
}
|
||||
|
||||
override fun appConsumesKey(keyCode: Int, event: KeyEvent): Boolean {
|
||||
keysOfferedToApp++
|
||||
return appHandles
|
||||
}
|
||||
|
||||
override fun cursorModes(): TerminalCursorModes? = modes
|
||||
override fun sendInput(data: String) {
|
||||
sent += data
|
||||
}
|
||||
|
||||
override fun onTapped() {
|
||||
taps++
|
||||
}
|
||||
|
||||
override fun onViewSize(widthPx: Int, heightPx: Int) {
|
||||
sizes += widthPx to heightPx
|
||||
}
|
||||
}
|
||||
|
||||
private fun downEvent() = KeyEvent(KeyEvent.ACTION_DOWN, 0)
|
||||
private fun upEvent() = KeyEvent(KeyEvent.ACTION_UP, 0)
|
||||
|
||||
@Test
|
||||
fun `a key press with a null session emits bytes and is consumed`() {
|
||||
val sink = FakeSink()
|
||||
val client = WebTermTerminalViewClient(sink)
|
||||
|
||||
// `session = null` is what the stock view actually passes — the very argument that made the old
|
||||
// "return false and let Termux write it" shape impossible.
|
||||
val consumed = client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null)
|
||||
|
||||
assertTrue(consumed, "returning false would let TerminalView fall through toward mTermSession")
|
||||
assertEquals(listOf("\u001b[A"), sink.sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the emulator's live DECCKM mode selects SS3 over CSI`() {
|
||||
val sink = FakeSink()
|
||||
val client = WebTermTerminalViewClient(sink)
|
||||
|
||||
client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null)
|
||||
sink.setCursorApplicationMode(true)
|
||||
client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null)
|
||||
|
||||
assertEquals(listOf("\u001b[A", "\u001bOA"), sink.sent, "vim/htop need SS3 once DECCKM is set")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Enter sends CR`() {
|
||||
val sink = FakeSink()
|
||||
WebTermTerminalViewClient(sink).onKeyDown(KeyEvent.KEYCODE_ENTER, downEvent(), null)
|
||||
|
||||
assertEquals(listOf("\r"), sink.sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an app-handled chord is offered to the outlet and not double-sent`() {
|
||||
val sink = FakeSink(appHandles = true)
|
||||
val client = WebTermTerminalViewClient(sink)
|
||||
|
||||
val consumed = client.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, downEvent(), null)
|
||||
|
||||
assertTrue(consumed)
|
||||
assertEquals(1, sink.keysOfferedToApp, ":app's HardwareKeyRouter must get first refusal")
|
||||
assertTrue(sink.sent.isEmpty(), ":app already sent the bytes; sending again would double-type")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `key-up is consumed and sends nothing`() {
|
||||
val sink = FakeSink()
|
||||
val consumed = WebTermTerminalViewClient(sink).onKeyUp(KeyEvent.KEYCODE_DPAD_UP, upEvent())
|
||||
|
||||
assertTrue(consumed)
|
||||
assertTrue(sink.sent.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onCodePoint never falls through to the session`() {
|
||||
val sink = FakeSink()
|
||||
val handled = WebTermTerminalViewClient(sink).onCodePoint('c'.code, ctrlDown = true, session = null)
|
||||
|
||||
assertTrue(handled, "false here would let inputCodePoint reach mTermSession.writeCodePoint")
|
||||
assertEquals(listOf("\u0003"), sink.sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the back button is never mapped to escape`() {
|
||||
// True would send a system BACK press down TerminalView.onKeyDown's terminal branch (offset
|
||||
// 113-136), whose next stop is mTermSession.write(...). This flag is load-bearing, not cosmetic.
|
||||
assertFalse(WebTermTerminalViewClient(FakeSink()).shouldBackButtonBeMappedToEscape())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long press is consumed so the stock text-selection mode never starts`() {
|
||||
// TerminalView$1.onLongPress offsets 14-30: `if (mClient.onLongPress(e)) return;`. Returning false
|
||||
// lets it call startTextSelectionMode, whose ActionMode is a crash, not a feature — "Copy"
|
||||
// (TextSelectionCursorController$1.onActionItemClicked offsets 89-100) is
|
||||
// `mTermSession.onCopyTextToClipboard(...)` and "Paste" (offsets 126-136) is
|
||||
// `mTermSession.onPasteTextFromClipboard()`, both unguarded against the session we never have.
|
||||
assertTrue(
|
||||
WebTermTerminalViewClient(FakeSink()).onLongPress(null),
|
||||
"false here arms an ActionMode whose Copy button NPEs on mTermSession",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap raises the keyboard and pinch-zoom is declined`() {
|
||||
val sink = FakeSink()
|
||||
val client = WebTermTerminalViewClient(sink)
|
||||
|
||||
client.onSingleTapUp(null)
|
||||
|
||||
assertEquals(1, sink.taps)
|
||||
assertEquals(1.0f, client.onScale(3.0f), "a changed font size would silently break the grid maths")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `logging callbacks are inert so keystrokes never reach logcat`() {
|
||||
val client = WebTermTerminalViewClient(FakeSink())
|
||||
// Termux funnels key codes and typed text through these; they must be no-ops (plan §8).
|
||||
client.logInfo("tag", "onKeyDown(keyCode=31, event=…)")
|
||||
client.logError("tag", "secret")
|
||||
client.logWarn("tag", "secret")
|
||||
client.logDebug("tag", "secret")
|
||||
client.logVerbose("tag", "secret")
|
||||
client.logStackTrace("tag", Exception("secret"))
|
||||
client.logStackTraceWithMessage("tag", "secret", Exception("secret"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no modifier latching leaks into the key tables`() {
|
||||
val client = WebTermTerminalViewClient(FakeSink())
|
||||
// Termux's extra-keys bar latches these; ours sends finished byte sequences instead, so a latched
|
||||
// "true" here would silently add Ctrl/Alt to every subsequent keystroke.
|
||||
assertFalse(client.readControlKey())
|
||||
assertFalse(client.readAltKey())
|
||||
assertFalse(client.readShiftKey())
|
||||
assertFalse(client.readFnKey())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user