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:
parent
c4f60b05cb
commit
65df2338a7
5 changed files with 269 additions and 14 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}|")
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
lib-terminal-view/src/test/resources/vttest_cursor_movements.bin
Normal file
BIN
lib-terminal-view/src/test/resources/vttest_cursor_movements.bin
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue