From 7e3439c8f64fe80540766142067cd0995059832e Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 30 Aug 2026 16:05:09 +0700 Subject: [PATCH] original delivery --- .gitignore | 11 + README.md | 36 +++ app/build.gradle.kts | 16 ++ app/src/main/AndroidManifest.xml | 10 + .../java/com/bozz/kosssh/ssh/SshClient.kt | 58 ++++ .../com/bozz/kosssh/terminal/TerminalView.kt | 158 +++++++++++ .../java/com/bozz/kosssh/ui/MainActivity.kt | 57 ++++ app/src/main/res/layout/activity_main.xml | 24 ++ app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/themes.xml | 6 + build.gradle.kts | 4 + gradle.properties | 3 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++++++++++++++ gradlew.bat | 93 +++++++ settings.gradle.kts | 4 + 17 files changed, 736 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/bozz/kosssh/ssh/SshClient.kt create mode 100644 app/src/main/java/com/bozz/kosssh/terminal/TerminalView.kt create mode 100644 app/src/main/java/com/bozz/kosssh/ui/MainActivity.kt create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2134d54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.gradle/ +build/ +/build/ +app/build/ +local.properties +.idea/ +*.iml +.DS_Store +/captures +.externalNativeBuild +.cxx diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c90931 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# KosSSH Mobile + +Android SSH mobile client starter project with interactive PTY, ANSI color rendering, custom terminal font size, keyboard helper keys, and password authentication. + +## Build + +Open this folder in Android Studio (Ladybug or newer) and let Gradle sync. Then run on an Android 8.0+ device/emulator. + +Or from a machine with Gradle/Android SDK configured: + +```bash +./gradlew assembleDebug +``` + +APK output: +`app/build/outputs/apk/debug/app-debug.apk` + +## Current features + +- SSH shell over JSch (SSH-2) +- PTY `xterm-256color` +- Interactive keyboard via InputConnection +- ANSI SGR basic 16-color + bright color + bold +- Adjustable monospace font size +- Dark terminal theme +- Ctrl+A-Z helper +- Tab/Esc/arrow helper buttons +- Host/port/user/password fields + +## Important production hardening + +This is a functional MVP, not yet a production-grade SSH client. Before shipping, replace `StrictHostKeyChecking=no` in `SshClient.kt` with persistent known-hosts / host-key fingerprint verification, add encrypted profile storage with Android Keystore, private-key authentication, SFTP, terminal resize (`windowChange`), robust VT100/VT220/VT420 parsing, selection/copy, scrollback UI, and connection lifecycle handling. + +## Package + +`com.bozz.kosssh` diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..9b9827a --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { namespace = "com.bozz.kosssh"; compileSdk = 37 + defaultConfig { applicationId = "com.bozz.kosssh"; minSdk = 26; targetSdk = 35; versionCode = 1; versionName = "1.0.0" } + buildToolsVersion = "35.0.0" +} + +dependencies { + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("com.github.mwiede:jsch:0.2.23") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..428b9b5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/main/java/com/bozz/kosssh/ssh/SshClient.kt b/app/src/main/java/com/bozz/kosssh/ssh/SshClient.kt new file mode 100644 index 0000000..ec5009e --- /dev/null +++ b/app/src/main/java/com/bozz/kosssh/ssh/SshClient.kt @@ -0,0 +1,58 @@ +package com.bozz.kosssh.ssh + +import com.jcraft.jsch.ChannelShell +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session +import java.io.InputStream +import java.io.OutputStream + +class SshClient { + private var session: Session? = null + private var channel: ChannelShell? = null + private var input: InputStream? = null + private var output: OutputStream? = null + + fun connect(host: String, port: Int, user: String, password: String, + onData: (String) -> Unit, onState: (String) -> Unit) { + Thread { + try { + onState("Connecting…") + val jsch = JSch() + val s = jsch.getSession(user, host, port) + s.setPassword(password) + s.setConfig("StrictHostKeyChecking", "no") // MVP; replace with known_hosts verification before production. + s.connect(12_000) + session = s + val c = s.openChannel("shell") as ChannelShell + c.setPtyType("xterm-256color", 120, 40, 0, 0) + c.setEnv("TERM", "xterm-256color") + c.connect(8_000) + channel = c + input = c.inputStream + output = c.outputStream + onState("Connected") + val buf = ByteArray(8192) + while (!c.isClosed) { + val n = input!!.read(buf) + if (n > 0) onData(String(buf, 0, n, Charsets.UTF_8)) + } + onState("Disconnected") + } catch (e: Exception) { + onState("Error: ${e.message ?: e.javaClass.simpleName}") + close() + } + }.start() + } + + fun write(bytes: ByteArray) { + try { output?.write(bytes); output?.flush() } catch (_: Exception) { } + } + + fun write(text: String) = write(text.toByteArray(Charsets.UTF_8)) + + fun close() { + try { channel?.disconnect() } catch (_: Exception) {} + try { session?.disconnect() } catch (_: Exception) {} + channel = null; session = null; input = null; output = null + } +} diff --git a/app/src/main/java/com/bozz/kosssh/terminal/TerminalView.kt b/app/src/main/java/com/bozz/kosssh/terminal/TerminalView.kt new file mode 100644 index 0000000..7ee72f1 --- /dev/null +++ b/app/src/main/java/com/bozz/kosssh/terminal/TerminalView.kt @@ -0,0 +1,158 @@ +package com.bozz.kosssh.terminal + +import android.content.Context +import android.graphics.* +import android.text.InputType +import android.view.* +import android.view.inputmethod.* +import java.util.ArrayDeque +import kotlin.math.max + +class TerminalView(context: Context) : View(context) { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE } + private val lines = ArrayDeque>() + private var current = mutableListOf() + private var cursorX = 0 + private var cursorY = 0 + private var fg = Color.rgb(230, 237, 243) + private var bg = Color.rgb(13, 17, 23) + private var bold = false + private var escape = "" + var fontSizePx = 15f + set(v) { field = v.coerceIn(8f, 40f); invalidate() } + var cursorColor = Color.rgb(126, 231, 135) + private var ctrlLatch = false + private var sender: ((ByteArray) -> Unit)? = null + private val scrollback = 2000 + + data class Cell(val ch: Char, val color: Int, val bold: Boolean) + + init { + isFocusableInTouchMode = true + setBackgroundColor(bg) + clear() + } + + fun setSender(s: (ByteArray) -> Unit) { sender = s } + fun clear() { lines.clear(); current = mutableListOf(); cursorX = 0; cursorY = 0; invalidate() } + fun setTheme(background: Int, foreground: Int) { bg = background; fg = foreground; setBackgroundColor(bg); invalidate() } + + fun feed(data: String) { + data.forEach { ch -> process(ch) } + invalidate() + } + + private fun process(ch: Char) { + if (escape.isNotEmpty() || ch == '\u001b') { processEscape(ch); return } + when (ch) { + '\r' -> cursorX = 0 + '\n' -> newLine() + '\b' -> cursorX = max(0, cursorX - 1) + '\t' -> repeat(4) { put(' ') } + '\u0007' -> Unit + else -> if (ch >= ' ') put(ch) + } + } + + private fun put(ch: Char) { + while (current.size < cursorX) current.add(Cell(' ', fg, bold)) + if (cursorX < current.size) current[cursorX] = Cell(ch, fg, bold) else current.add(Cell(ch, fg, bold)) + cursorX++ + if (cursorX >= 200) newLine() + } + + private fun newLine() { + lines.addLast(current); if (lines.size > scrollback) lines.removeFirst() + current = mutableListOf(); cursorX = 0; cursorY++ + } + + private fun processEscape(ch: Char) { + if (escape.isEmpty()) { escape = "\u001b"; return } + escape += ch + if (ch == 'm') { sgr(escape.drop(2).dropLast(1)); escape = ""; return } + if (escape.startsWith("\u001b[") && ch in "ABCDEFGHJKfnsu") { escape = ""; return } + if (escape.length > 32) escape = "" + } + + private fun sgr(seq: String) { + if (seq.isEmpty()) { fg = Color.rgb(230,237,243); bold = false; return } + seq.split(';').mapNotNull { it.toIntOrNull() }.forEach { n -> + when { + n == 0 -> { fg = Color.rgb(230,237,243); bold = false } + n == 1 -> bold = true + n == 22 -> bold = false + n in 30..37 -> fg = ansi16(n - 30) + n in 90..97 -> fg = ansi16(n - 90 + 8) + n == 39 -> fg = Color.rgb(230,237,243) + } + } + } + + private fun ansi16(i: Int): Int = intArrayOf( + Color.rgb(72,79,88), Color.rgb(255,123,114), Color.rgb(126,231,135), Color.rgb(210,153,34), + Color.rgb(121,192,255), Color.rgb(210,168,255), Color.rgb(86,212,221), Color.rgb(240,246,252), + Color.rgb(139,148,158), Color.rgb(255,123,114), Color.rgb(126,231,135), Color.rgb(227,179,65), + Color.rgb(121,192,255), Color.rgb(210,168,255), Color.rgb(86,212,221), Color.WHITE + )[i.coerceIn(0,15)] + + override fun onDraw(c: Canvas) { + super.onDraw(c) + paint.textSize = fontSizePx + paint.typeface = Typeface.MONOSPACE + val fm = paint.fontMetrics + val lineH = (fm.descent - fm.ascent) * 1.12f + val charW = paint.measureText("M") + val visible = height / lineH + val all = lines.toList() + listOf(current) + val start = max(0, all.size - visible.toInt()) + var y = -fm.ascent + for (i in start until all.size) { + var x = 0f + for (cell in all[i]) { + paint.color = cell.color + paint.typeface = if (cell.bold) Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) else Typeface.MONOSPACE + c.drawText(cell.ch.toString(), x, y, paint); x += charW + } + y += lineH + } + paint.color = cursorColor + val cursorLine = all.size - 1 + if (cursorLine >= start) c.drawRect(cursorX * charW, y - lineH, cursorX * charW + 2, y - 2, paint) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_DOWN) { requestFocus(); showKeyboard(); return true } + return true + } + + private fun showKeyboard() { (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } + + override fun onCheckIsTextEditor() = 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 + return object : BaseInputConnection(this, false) { + override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean { send(text.toString()); return true } + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { send("\u007f".repeat(max(1,beforeLength))); return true } + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + val seq = when (keyCode) { + KeyEvent.KEYCODE_ENTER -> "\r" + KeyEvent.KEYCODE_TAB -> "\t" + KeyEvent.KEYCODE_DEL -> "\u007f" + KeyEvent.KEYCODE_ESCAPE -> "\u001b" + KeyEvent.KEYCODE_DPAD_UP -> "\u001b[A" + KeyEvent.KEYCODE_DPAD_DOWN -> "\u001b[B" + KeyEvent.KEYCODE_DPAD_LEFT -> "\u001b[D" + KeyEvent.KEYCODE_DPAD_RIGHT -> "\u001b[C" + else -> if (event.unicodeChar != 0) event.unicodeChar.toChar().toString() else null + } + if (seq != null) { send(seq); return true } + return super.onKeyDown(keyCode, event) + } + + fun sendCtrl(letter: Char) { send(byteArrayOf((letter.uppercaseChar().code - 64).toByte())) } + private fun send(s: String) { sender?.invoke(s.toByteArray(Charsets.UTF_8)) } +} diff --git a/app/src/main/java/com/bozz/kosssh/ui/MainActivity.kt b/app/src/main/java/com/bozz/kosssh/ui/MainActivity.kt new file mode 100644 index 0000000..568efcf --- /dev/null +++ b/app/src/main/java/com/bozz/kosssh/ui/MainActivity.kt @@ -0,0 +1,57 @@ +package com.bozz.kosssh.ui + +import android.app.* +import android.graphics.Color +import android.os.Bundle +import android.view.View +import android.widget.* +import com.bozz.kosssh.R +import com.bozz.kosssh.ssh.SshClient +import com.bozz.kosssh.terminal.TerminalView + +class MainActivity : Activity() { + private lateinit var terminal: TerminalView + private val ssh = SshClient() + private lateinit var status: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + terminal = findViewById(R.id.terminal) + status = findViewById(R.id.status) + terminal.setSender { ssh.write(it) } + findViewById