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
|
// 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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