Terminal: tampilkan soft keyboard + perbaiki scroll & newline

PROBLEM: setelah konek SSH, prompt tidak keluar & keyboard tidak muncul.

FIX (input):
- TerminalView: implementasi InputConnection (BaseInputConnection) jadi soft
  keyboard bisa muncul; onCheckIsTextEditor=true; commitText/key events
  diteruskan ke SSH session (onKeyInput). Handle hardware keyboard: Ctrl+letter
  -> ascii control, Enter/panah/PgUp/PgDn/Home/End/Esc/Tab/Del.
- TerminalController.attach(): hubungkan view.onKeyInput -> engine.write()
- TerminalActivity: requestFocus() + showKeyboard() saat 'Terhubung'.

FIX (render — bug ditemukan via unit test baru):
- TerminalModel.write('\n') kini reset cursorCol=0 (LF berperilaku seperti
  LF+CR). Sebelumnya LF tidak reset kolom sehingga baris lanjutan ditulis di
  posisi salah & scroll ganda terjadi.
- TerminalModel.scrollUp() tidak lagi mengubah cursorRow (pemanggil yang atur).
- Tambah 8+ unit test (TerminalParserTest): prompt shell, CRLF, truecolor SGR,
  erase, scroll, multiline. Semua 10 lulus.

verified: gradle :app:testDebugUnitTest BUILD SUCCESSFUL (10 tests, 0 fail);
gradle :app:assembleDebug BUILD SUCCESSFUL.
This commit is contained in:
2026-08-31 00:20:04 +07:00
parent 1040b19448
commit 2a75f886e4
6 changed files with 243 additions and 3 deletions

View File

@@ -64,4 +64,7 @@ dependencies {
// Coroutines // Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// Unit tests (JVM) — TerminalModel & TerminalParser are pure Kotlin (no Android)
testImplementation("junit:junit:4.13.2")
} }

View File

@@ -29,6 +29,9 @@ class TerminalController(
override fun onError(message: String) = statusBar(message) override fun onError(message: String) = statusBar(message)
}) })
engine.setOnData { bytes -> inbound(bytes) } engine.setOnData { bytes -> inbound(bytes) }
// Route terminal keyboard/IME input to the SSH session.
view.onKeyInput = { bytes -> engine.write(bytes) }
} }
/** Remote SSH bytes -> ANSI parser -> render. */ /** Remote SSH bytes -> ANSI parser -> render. */

View File

@@ -94,7 +94,8 @@ class TerminalModel(
return screen[row][col] return screen[row][col]
} }
/** Scroll the screen up one line, pushing the top line into scrollback. */ /** Scroll the screen up one line, pushing the top line into scrollback.
* The caller is responsible for adjusting the cursor if needed. */
fun scrollUp() { fun scrollUp() {
// capture line 0 as text // capture line 0 as text
val topText = StringBuilder() val topText = StringBuilder()
@@ -107,7 +108,6 @@ class TerminalModel(
screen[r] = screen[r + 1] screen[r] = screen[r + 1]
} }
screen[rows - 1] = blankRow() screen[rows - 1] = blankRow()
cursorRow = cursorRow.coerceAtLeast(1) - 1
} }
/** Scroll down one line, pulling the newest line out of scrollback. */ /** Scroll down one line, pulling the newest line out of scrollback. */
@@ -158,6 +158,7 @@ class TerminalModel(
for (ch in data) { for (ch in data) {
when (ch) { when (ch) {
'\n' -> { '\n' -> {
cursorCol = 0
if (cursorRow == rows - 1) scrollUp() if (cursorRow == rows - 1) scrollUp()
else cursorRow++ else cursorRow++
} }

View File

@@ -7,10 +7,16 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Typeface import android.graphics.Typeface
import android.text.InputType
import android.view.GestureDetector import android.view.GestureDetector
import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.ScaleGestureDetector import android.view.ScaleGestureDetector
import android.view.View import android.view.View
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import com.kosbarokah.sshclient.keyboard.KeySequences
/** /**
* Custom View that renders a [TerminalModel] grid in a monospace font. * Custom View that renders a [TerminalModel] grid in a monospace font.
@@ -69,6 +75,87 @@ class TerminalView @JvmOverloads constructor(
isFocusableInTouchMode = true isFocusableInTouchMode = true
} }
/**
* Callback invoked with the raw bytes to send to the SSH session.
* Set by the controller/activity; receives printable text, Enter, arrows,
* Ctrl-combos, etc. translated from key events / IME.
*/
var onKeyInput: ((ByteArray) -> Unit)? = null
// ---- input: allow the terminal to show the soft keyboard ----
override fun onCheckIsTextEditor(): Boolean = true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_ACTION_NONE
return object : BaseInputConnection(this, true) {
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
text?.let { sendBytes(it.toString().toByteArray(Charsets.UTF_8)) }
return true
}
override fun sendKeyEvent(event: KeyEvent): Boolean {
handleKeyEvent(event)
return true
}
override fun performEditorAction(actionCode: Int): Boolean {
sendBytes(KeySequences.enter)
return true
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
repeat(beforeLength) { sendBytes(byteArrayOf(0x7f)) } // backspace
return true
}
}
}
/** Hardware keyboard / Ctrl+letter / Enter handling. */
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (handleKeyEvent(event)) return true
return super.onKeyDown(keyCode, event)
}
private fun handleKeyEvent(event: KeyEvent): Boolean {
if (event.action != KeyEvent.ACTION_DOWN) return true
// Ctrl+letter → send ascii control code (Ctrl+C=0x03, Ctrl+L=0x0c...)
if (event.isCtrlPressed) {
val ch = event.unicodeChar
if (ch in 'a'.code..'z'.code || ch in 'A'.code..'Z'.code) {
sendBytes(byteArrayOf((ch and 0x1f).toByte()))
return true
}
}
return when (event.keyCode) {
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
sendBytes(KeySequences.enter); true
}
KeyEvent.KEYCODE_BACK -> false // let the parent/system handle back
KeyEvent.KEYCODE_DPAD_UP -> { sendBytes(KeySequences.up()); true }
KeyEvent.KEYCODE_DPAD_DOWN -> { sendBytes(KeySequences.down()); true }
KeyEvent.KEYCODE_DPAD_LEFT -> { sendBytes(KeySequences.left()); true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { sendBytes(KeySequences.right()); true }
KeyEvent.KEYCODE_DEL -> { sendBytes(KeySequences.backspace); true }
KeyEvent.KEYCODE_TAB -> { sendBytes(KeySequences.tab); true }
KeyEvent.KEYCODE_PAGE_UP -> { sendBytes(KeySequences.pageUp); true }
KeyEvent.KEYCODE_PAGE_DOWN -> { sendBytes(KeySequences.pageDown); true }
KeyEvent.KEYCODE_MOVE_HOME -> { sendBytes(KeySequences.home); true }
KeyEvent.KEYCODE_MOVE_END -> { sendBytes(KeySequences.end); true }
KeyEvent.KEYCODE_ESCAPE -> { sendBytes(KeySequences.esc); true }
else -> event.unicodeChar.takeIf { it > 0 }?.let {
sendBytes(String(Character.toChars(it)).toByteArray(Charsets.UTF_8)); true
} ?: false
}
}
/** Forward bytes (from IME or keys) to the SSH session via the callback. */
private fun sendBytes(data: ByteArray) {
onKeyInput?.invoke(data)
}
// ---- helpers for sizing ---- // ---- helpers for sizing ----
fun computeCols(pixelWidth: Int): Int { fun computeCols(pixelWidth: Int): Int {
val cw = cellWidth() val cw = cellWidth()

View File

@@ -6,6 +6,7 @@ import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
@@ -98,7 +99,12 @@ class TerminalActivity : AppCompatActivity() {
controller.attach( controller.attach(
engine = engine, engine = engine,
statusBar = { msg -> runOnUiThread { statusBar.text = msg } } statusBar = { msg ->
runOnUiThread {
statusBar.text = msg
if (msg == "Terhubung") showKeyboard()
}
}
) )
// Keep PTY rows/cols in sync when the terminal lays out. // Keep PTY rows/cols in sync when the terminal lays out.
@@ -110,9 +116,19 @@ class TerminalActivity : AppCompatActivity() {
setupCustomKeyboard() setupCustomKeyboard()
setDarkLightUi(true) setDarkLightUi(true)
terminalView.requestFocus()
engine.start() engine.start()
} }
/** Fokuskan terminal & tampilkan soft keyboard + baris tombol. */
private fun showKeyboard() {
try {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(terminalView, InputMethodManager.SHOW_IMPLICIT)
} catch (_: Exception) {
}
}
private fun setupCustomKeyboard() { private fun setupCustomKeyboard() {
// Row of helper keys: send sequences directly to the SSH session. // Row of helper keys: send sequences directly to the SSH session.
fun add(text: String, seq: ByteArray) { fun add(text: String, seq: ByteArray) {

View File

@@ -0,0 +1,130 @@
package com.kosbarokah.sshclient.terminal
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* JVM unit tests for the self-contained terminal model + parser.
* Both classes are pure Kotlin (no Android deps) so they run on the JVM.
*/
class TerminalParserTest {
private fun model(): TerminalModel = TerminalModel(
palette = IntArray(16) { it },
bgDefault = 0,
fgDefault = 0xFFFFFF,
cursorColor = 0x00FF00
)
/** Render the visible screen rows into a list of trimmed strings. */
private fun TerminalModel.render(): List<String> =
(0 until height()).map { r ->
(0 until width()).map { c -> cellAt(c, r).ch }.joinToString("").trimEnd()
}
@Test
fun plainTextWithNewlineRenders() {
val m = model()
val p = TerminalParser(m)
p.feed("hello\r\nworld: $ ".toByteArray(Charsets.UTF_8))
val lines = m.render()
assertEquals("hello", lines[0])
assertTrue("world line should contain prompt text", lines[1].contains("world:"))
}
@Test
fun crlfAndPromptLine() {
val m = model()
val p = TerminalParser(m)
p.feed("user@host:~$ ".toByteArray(Charsets.UTF_8))
assertEquals("user@host:~$", m.render()[0])
}
@Test
fun backspaceMovesCursorOnly() {
val m = model()
val p = TerminalParser(m)
p.feed("ab\u0008".toByteArray(Charsets.UTF_8)) // \u0008 = BS
// 'a' and 'b' written, backspace moves cursor 1 left (no delete)
assertEquals("ab", m.render()[0])
}
@Test
fun sgrColorsParsedWithoutCrash() {
val m = model()
val p = TerminalParser(m)
p.feed("\u001b[31mred\u001b[0m normal".toByteArray(Charsets.UTF_8))
val line = m.render()[0]
assertTrue(line.contains("red"))
assertTrue(line.contains("normal"))
}
@Test
fun trueColorSgrParsed() {
val m = model()
val p = TerminalParser(m)
p.feed("\u001b[38;2;255;0;0mX".toByteArray(Charsets.UTF_8))
assertEquals("X", m.render()[0])
// fg should now be the truecolor rgb(255,0,0)
assertEquals(0xFF0000.toInt(), m.cellAt(0, 0).fg)
}
@Test
fun eraseScreenClearsCells() {
val m = model()
val p = TerminalParser(m)
p.feed("abc\u001b[2J".toByteArray(Charsets.UTF_8))
// After erase whole screen, line 0 should be blank
assertEquals("", m.render()[0])
}
@Test
fun scrollPushesLineToScrollback() {
val m = model()
m.resize(10, 3)
val p = TerminalParser(m)
p.feed("111\n222\n333\n444".toByteArray(Charsets.UTF_8))
// After 4 lines on a 3-row screen, top line scrolled off
assertTrue(m.scrollbackLines().isNotEmpty())
// visible rows should not be blank
val joined = m.render().joinToString("|")
assertTrue("visible rows missing 444; got: [$joined] cursorRow=${m.getCursorRow()}", joined.contains("444"))
}
@Test
fun realisticShellCrlfPrompt() {
// What a real bash login emits: banner line CRLF + prompt CRLF.
val m = model()
val p = TerminalParser(m)
p.feed("Welcome to Ubuntu 22.04.5 LTS\r\nuser@host:~$ ".toByteArray(Charsets.UTF_8))
val lines = m.render()
assertEquals("Welcome to Ubuntu 22.04.5 LTS", lines[0])
assertTrue(lines[1].contains("user@host:~$"))
}
@Test
fun multilineOutputWithScrollback() {
val m = model()
m.resize(15, 4)
val p = TerminalParser(m)
val sb = StringBuilder()
for (i in 0 until 6) sb.append("line$i\r\n")
sb.append("prompt$ ")
p.feed(sb.toString().toByteArray(Charsets.UTF_8))
// rows=4, so 6 lines scroll -> some scrollback
assertTrue(m.scrollbackLines().isNotEmpty())
// last visible line is the prompt
assertTrue(m.render()[3].contains("prompt$"))
}
@Test
fun controlEnterClearsToRight() {
val m = model()
val p = TerminalParser(m)
p.feed("abc\u001b[0Kxyz".toByteArray(Charsets.UTF_8))
// CSI 0K erase to end of line on row 0 from col3 -> clears nothing already there
// (xyz lands after cursor)
assertTrue(m.render()[0].contains("xyz"))
}
}