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:
@@ -64,4 +64,7 @@ dependencies {
|
||||
|
||||
// Coroutines
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ class TerminalController(
|
||||
override fun onError(message: String) = statusBar(message)
|
||||
})
|
||||
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. */
|
||||
|
||||
@@ -94,7 +94,8 @@ class TerminalModel(
|
||||
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() {
|
||||
// capture line 0 as text
|
||||
val topText = StringBuilder()
|
||||
@@ -107,7 +108,6 @@ class TerminalModel(
|
||||
screen[r] = screen[r + 1]
|
||||
}
|
||||
screen[rows - 1] = blankRow()
|
||||
cursorRow = cursorRow.coerceAtLeast(1) - 1
|
||||
}
|
||||
|
||||
/** Scroll down one line, pulling the newest line out of scrollback. */
|
||||
@@ -158,6 +158,7 @@ class TerminalModel(
|
||||
for (ch in data) {
|
||||
when (ch) {
|
||||
'\n' -> {
|
||||
cursorCol = 0
|
||||
if (cursorRow == rows - 1) scrollUp()
|
||||
else cursorRow++
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.text.InputType
|
||||
import android.view.GestureDetector
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
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.
|
||||
@@ -69,6 +75,87 @@ class TerminalView @JvmOverloads constructor(
|
||||
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 ----
|
||||
fun computeCols(pixelWidth: Int): Int {
|
||||
val cw = cellWidth()
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
@@ -98,7 +99,12 @@ class TerminalActivity : AppCompatActivity() {
|
||||
|
||||
controller.attach(
|
||||
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.
|
||||
@@ -110,9 +116,19 @@ class TerminalActivity : AppCompatActivity() {
|
||||
|
||||
setupCustomKeyboard()
|
||||
setDarkLightUi(true)
|
||||
terminalView.requestFocus()
|
||||
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() {
|
||||
// Row of helper keys: send sequences directly to the SSH session.
|
||||
fun add(text: String, seq: ByteArray) {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user