diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d9707f..1aca08d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } diff --git a/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalController.kt b/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalController.kt index 5cba180..8916c27 100644 --- a/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalController.kt +++ b/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalController.kt @@ -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. */ diff --git a/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalModel.kt b/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalModel.kt index 84506e1..f623025 100644 --- a/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalModel.kt +++ b/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalModel.kt @@ -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++ } diff --git a/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalView.kt b/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalView.kt index d130b1d..937a341 100644 --- a/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalView.kt +++ b/app/src/main/java/com/kosbarokah/sshclient/terminal/TerminalView.kt @@ -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() diff --git a/app/src/main/java/com/kosbarokah/sshclient/ui/TerminalActivity.kt b/app/src/main/java/com/kosbarokah/sshclient/ui/TerminalActivity.kt index 191af04..948d2c1 100644 --- a/app/src/main/java/com/kosbarokah/sshclient/ui/TerminalActivity.kt +++ b/app/src/main/java/com/kosbarokah/sshclient/ui/TerminalActivity.kt @@ -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) { diff --git a/app/src/test/java/com/kosbarokah/sshclient/terminal/TerminalParserTest.kt b/app/src/test/java/com/kosbarokah/sshclient/terminal/TerminalParserTest.kt new file mode 100644 index 0000000..58cd347 --- /dev/null +++ b/app/src/test/java/com/kosbarokah/sshclient/terminal/TerminalParserTest.kt @@ -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 = + (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")) + } +}