Launch intent profile auto-connect, PTY size fix, vttest parity test, screen dump

- Add --es profile "Name" launch intent to auto-connect by connection name/nickname
- Add --ez clearLog true intent extra to clear debug log on launch
- Add --ez dump true ADB broadcast to dump full screen buffer to log file
- Fix PTY allocation to use actual terminal dimensions instead of hardcoded 80x24:
  SSHSession.resize() now stores dimensions even while Connecting, and PTY
  allocation picks up the latest values instead of the initial config
- Add connectByProfile() to MainViewModel for profile-based connections
- Add VttestParityTest: replays recorded vttest session through both our engine
  and Termux at 80x24 and 102x38, verifying cell-by-cell parity (both pass)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-03-30 23:58:28 +02:00
parent c4f60b05cb
commit 65df2338a7
5 changed files with 269 additions and 14 deletions

View file

@ -93,6 +93,11 @@ class MainActivity : AppCompatActivity() {
terminalService = svc
serviceBound = true
mainViewModel.onServiceBound(svc)
// Auto-connect pending profile from launch intent
pendingProfile?.let {
mainViewModel.connectByProfile(it)
pendingProfile = null
}
}
override fun onServiceDisconnected(name: ComponentName?) {
@ -123,6 +128,10 @@ class MainActivity : AppCompatActivity() {
// Handle shared QR image (from WhatsApp/Telegram/etc.)
handleSharedImage(intent)
// Handle launch extras: --es profile "name" to auto-connect,
// --ez clearLog true to clear the debug log
handleLaunchExtras(intent)
setContent {
SshWorkbenchTheme {
if (isLocked) {
@ -398,6 +407,31 @@ class MainActivity : AppCompatActivity() {
private val TAG = "MainActivity"
// ========================================================================
// Launch extras: --es profile "Name" --ez clearLog true
// ========================================================================
/** Profile name from launch intent — connected once the service is bound. */
private var pendingProfile: String? = null
private fun handleLaunchExtras(intent: Intent?) {
intent ?: return
if (intent.getBooleanExtra("clearLog", false)) {
clearLog()
}
val profileName = intent.getStringExtra("profile")
if (profileName != null) {
pendingProfile = profileName
// If service is already bound, connect now; otherwise onServiceBound handles it
if (serviceBound) {
mainViewModel.connectByProfile(profileName)
pendingProfile = null
}
}
}
// ========================================================================
// ADB broadcast receiver (debug only)
// ========================================================================
@ -458,6 +492,20 @@ class MainActivity : AppCompatActivity() {
val marker = line.take(col) + "" + line.drop(col)
FileLogger.log("ADBReceiver", "cursor=$row,$col font=${fontSz}sp grid=${buf.cols}x${buf.rows} line=$marker")
}
// Dump full screen buffer to log: --ez dump true
val dump = intent.getBooleanExtra("dump", false)
if (dump) {
val svc = mainViewModel.terminalService ?: return
val entry = svc.getSession(activeId) ?: return
val buf = entry.screenBuffer
FileLogger.log("SCREEN", "┌─ dump session=$activeId ${buf.cols}x${buf.rows} cursor=${buf.cursorRow},${buf.cursorCol} ─┐")
for (r in 0 until buf.rows) {
val line = buf.getLine(r).trimEnd('\u0000', ' ')
FileLogger.log("SCREEN", "%3d│%s│".format(r, line))
}
FileLogger.log("SCREEN", "└─ end dump ─┘")
}
}
}
val filter = IntentFilter(ADB_INPUT_ACTION)
@ -534,7 +582,6 @@ class MainActivity : AppCompatActivity() {
logFile.writeText("")
FileLogger.init(this, useDownloadsDir = FileLogger.useDownloads)
FileLogger.log("MainActivity", "Log cleared by user")
Toast.makeText(this, "Log cleared", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "Failed: ${e.message}", Toast.LENGTH_LONG).show()
FileLogger.logError("MainActivity", "clearLog failed", e)

View file

@ -565,6 +565,26 @@ class MainViewModel @Inject constructor(
}
}
// --- Auto-connect by profile name (for launch intents) ---
fun connectByProfile(profileName: String) {
val svc = terminalService ?: return
viewModelScope.launch(Dispatchers.IO) {
val all = connectionDao.getAllOnce()
val match = all.firstOrNull { it.name.equals(profileName, ignoreCase = true)
|| it.nickname.equals(profileName, ignoreCase = true) }
if (match != null) {
val password = credentialStore.getPassword(match.id) ?: ""
FileLogger.log("MainVM", "connectByProfile '${profileName}' → ${match.username}@${match.host}:${match.port}")
launch(Dispatchers.Main) {
connect(match.host, match.port, match.username, password, match.keyId)
}
} else {
FileLogger.log("MainVM", "connectByProfile '${profileName}' not found")
}
}
}
// --- Write input to active session ---
fun writeToSession(sessionId: Long, bytes: ByteArray) {

View file

@ -235,11 +235,15 @@ class SSHSession {
log("connect: authenticated, opening session")
val sess = client.startSession()
log("connect: allocating PTY ${config.ptyConfig.termType} ${config.ptyConfig.cols}x${config.ptyConfig.rows}")
// Use the latest known dimensions if a resize arrived during auth,
// otherwise fall back to the config values.
val ptyCols = if (currentPtyCols > 0) currentPtyCols else config.ptyConfig.cols
val ptyRows = if (currentPtyRows > 0) currentPtyRows else config.ptyConfig.rows
log("connect: allocating PTY ${config.ptyConfig.termType} ${ptyCols}x${ptyRows} (config=${config.ptyConfig.cols}x${config.ptyConfig.rows})")
sess.allocatePTY(
config.ptyConfig.termType,
config.ptyConfig.cols,
config.ptyConfig.rows,
ptyCols,
ptyRows,
config.ptyConfig.widthPixels,
config.ptyConfig.heightPixels,
emptyMap()
@ -251,8 +255,8 @@ class SSHSession {
sshClient = client
session = sess
shell = sh
currentPtyCols = config.ptyConfig.cols
currentPtyRows = config.ptyConfig.rows
currentPtyCols = ptyCols
currentPtyRows = ptyRows
// Get output stream — use a fresh non-buffered pipe
val rawOs = sh.outputStream
log("connect: outputStream type=${rawOs.javaClass.name}")
@ -651,16 +655,13 @@ class SSHSession {
* Dispatched to IO to avoid threading issues with SSHJ transport.
*/
fun resize(cols: Int, rows: Int) {
if (_state.value !is SessionState.Connected) {
log("resize: skipping, not connected (state=${_state.value})")
return
}
if (cols == currentPtyCols && rows == currentPtyRows) {
return
}
// Update immediately to dedup rapid back-to-back calls before the coroutine runs
// Always store latest dimensions so PTY allocation uses them if still connecting
currentPtyCols = cols
currentPtyRows = rows
if (_state.value !is SessionState.Connected) {
log("resize: stored ${cols}x${rows} for PTY allocation (state=${_state.value})")
return
}
scope.launch {
synchronized(writeLock) {
val sh = shell

View file

@ -0,0 +1,187 @@
package com.roundingmobile.terminalview.engine
import com.termux.terminal.TerminalEmulator
import com.termux.terminal.TerminalOutput
import com.termux.terminal.TerminalSessionClient
import com.termux.terminal.TerminalSession
import org.junit.Test
import java.io.DataInputStream
import java.nio.ByteBuffer
/**
* Replay a recorded vttest session through both our engine and Termux,
* then compare the screen buffers cell-by-cell to find rendering differences.
*/
class VttestParityTest {
companion object {
const val ROWS = 24
const val COLS = 80
}
private lateinit var ourScreen: ScreenBuffer
private lateinit var ourParser: XtermParser
private lateinit var termuxEmulator: TerminalEmulator
private fun initEngines() {
ourScreen = ScreenBuffer(ROWS, COLS, maxScrollback = 100)
ourParser = XtermParser(ourScreen)
val output = object : TerminalOutput() {
override fun write(data: ByteArray, offset: Int, count: Int) {}
override fun titleChanged(oldTitle: String?, newTitle: String?) {}
override fun onCopyTextToClipboard(text: String?) {}
override fun onPasteTextFromClipboard() {}
override fun onBell() {}
override fun onColorsChanged() {}
}
val client = object : TerminalSessionClient {
override fun onTextChanged(session: TerminalSession) {}
override fun onTitleChanged(session: TerminalSession) {}
override fun onSessionFinished(session: TerminalSession) {}
override fun onCopyTextToClipboard(session: TerminalSession, text: String) {}
override fun onPasteTextFromClipboard(session: TerminalSession?) {}
override fun onBell(session: TerminalSession) {}
override fun onColorsChanged(session: TerminalSession) {}
override fun onTerminalCursorStateChange(state: Boolean) {}
override fun getTerminalCursorStyle(): Int = 0
override fun logError(tag: String, message: String) {}
override fun logWarn(tag: String, message: String) {}
override fun logInfo(tag: String, message: String) {}
override fun logDebug(tag: String, message: String) {}
override fun logVerbose(tag: String, message: String) {}
override fun logStackTraceWithMessage(tag: String, message: String, e: Exception) {}
override fun logStackTrace(tag: String, e: Exception) {}
}
termuxEmulator = TerminalEmulator(output, COLS, ROWS, null, client)
}
/** Read a SessionRecorder .bin file: 4-byte BE length prefix per chunk, concatenated. */
private fun readRecording(resourceName: String): ByteArray {
val stream = javaClass.classLoader!!.getResourceAsStream(resourceName)
?: throw IllegalArgumentException("Recording not found: $resourceName")
val dis = DataInputStream(stream)
val allBytes = mutableListOf<Byte>()
try {
while (true) {
val len = try { dis.readInt() } catch (_: Exception) { break }
if (len <= 0 || len > 1_000_000) break
val chunk = ByteArray(len)
dis.readFully(chunk)
allBytes.addAll(chunk.toList())
}
} finally {
dis.close()
}
return allBytes.toByteArray()
}
private fun termuxCharAt(row: Int, col: Int): Char {
val screen = termuxEmulator.screen
val internalRow = screen.externalToInternalRow(row)
val line = screen.allocateFullLineIfNecessary(internalRow)
val charIdx = line.findStartOfColumn(col)
return if (charIdx < line.spaceUsed) {
val cp = Character.codePointAt(line.mText, charIdx)
if (cp in 0x20..0xFFFF) cp.toChar() else ' '
} else ' '
}
private fun ourLine(row: Int): String {
return (0 until COLS).map { c ->
val ch = ourScreen.getChar(row, c)
if (ch == '\u0000') ' ' else ch
}.joinToString("")
}
private fun termuxLine(row: Int): String {
return (0 until COLS).map { termuxCharAt(row, it) }.joinToString("")
}
private fun runParity(testName: String, rows: Int, cols: Int) {
ourScreen = ScreenBuffer(rows, cols, maxScrollback = 100)
ourParser = XtermParser(ourScreen)
val output = object : TerminalOutput() {
override fun write(data: ByteArray, offset: Int, count: Int) {}
override fun titleChanged(oldTitle: String?, newTitle: String?) {}
override fun onCopyTextToClipboard(text: String?) {}
override fun onPasteTextFromClipboard() {}
override fun onBell() {}
override fun onColorsChanged() {}
}
val client = object : TerminalSessionClient {
override fun onTextChanged(session: TerminalSession) {}
override fun onTitleChanged(session: TerminalSession) {}
override fun onSessionFinished(session: TerminalSession) {}
override fun onCopyTextToClipboard(session: TerminalSession, text: String) {}
override fun onPasteTextFromClipboard(session: TerminalSession?) {}
override fun onBell(session: TerminalSession) {}
override fun onColorsChanged(session: TerminalSession) {}
override fun onTerminalCursorStateChange(state: Boolean) {}
override fun getTerminalCursorStyle(): Int = 0
override fun logError(tag: String, message: String) {}
override fun logWarn(tag: String, message: String) {}
override fun logInfo(tag: String, message: String) {}
override fun logDebug(tag: String, message: String) {}
override fun logVerbose(tag: String, message: String) {}
override fun logStackTraceWithMessage(tag: String, message: String, e: Exception) {}
override fun logStackTrace(tag: String, e: Exception) {}
}
termuxEmulator = TerminalEmulator(output, cols, rows, null, client)
val data = readRecording("vttest_cursor_movements.bin")
println("[$testName] Recording: ${data.size} bytes, grid: ${cols}x${rows}")
ourParser.process(data, 0, data.size)
termuxEmulator.append(data, data.size)
println("Cursor — ours: (${ourScreen.cursorRow},${ourScreen.cursorCol}) termux: (${termuxEmulator.cursorRow},${termuxEmulator.cursorCol})")
val mismatches = mutableListOf<String>()
for (row in 0 until rows) {
val ours = (0 until cols).map { c -> val ch = ourScreen.getChar(row, c); if (ch == '\u0000') ' ' else ch }.joinToString("")
val termux = (0 until cols).map { termuxCharAt(row, it) }.joinToString("")
if (ours != termux) {
mismatches.add("Row $row")
println("ROW $row MISMATCH:")
println(" OURS: |${ours.trimEnd()}|")
println(" TERMUX: |${termux.trimEnd()}|")
for (col in 0 until cols) {
if (ours[col] != termux[col]) {
println(" col $col: ours='${ours[col]}'(${ours[col].code}) termux='${termux[col]}'(${termux[col].code})")
}
}
}
}
if (mismatches.isEmpty()) {
println("✓ All $rows rows match!\n")
} else {
println("${mismatches.size} mismatched rows: ${mismatches.joinToString(", ")}\n")
}
assert(mismatches.isEmpty()) {
"[$testName] Screen mismatch on ${mismatches.size} rows: ${mismatches.joinToString(", ")}"
}
}
@Test
fun `vttest cursor movements - 80x24`() = runParity("80x24", ROWS, COLS)
@Test
fun `vttest cursor movements - 102x38`() = runParity("102x38", 38, 102)
@Test
fun `vttest at 102x38 - print row 22 detail`() {
ourScreen = ScreenBuffer(38, 102, maxScrollback = 100)
ourParser = XtermParser(ourScreen)
val data = readRecording("vttest_cursor_movements.bin")
ourParser.process(data, 0, data.size)
// Print rows around the bottom border to see the issue
for (row in 20..25) {
val line = (0 until 102).map { c -> val ch = ourScreen.getChar(row, c); if (ch == '\u0000') ' ' else ch }.joinToString("")
println("Row $row: |${line}|")
}
}
}