original delivery

This commit is contained in:
2026-08-30 16:05:09 +07:00
commit 7e3439c8f6
17 changed files with 736 additions and 0 deletions

16
app/build.gradle.kts Normal file
View File

@@ -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")
}

View File

@@ -0,0 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application android:theme="@style/Theme.KosSSHMobile" android:label="KosSSH Mobile" android:allowBackup="true" android:supportsRtl="true">
<activity android:name=".ui.MainActivity" android:screenOrientation="unspecified" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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
}
}

View File

@@ -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<MutableList<Cell>>()
private var current = mutableListOf<Cell>()
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)) }
}

View File

@@ -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<Button>(R.id.connect).setOnClickListener { connect() }
findViewById<Button>(R.id.ctrl).setOnClickListener { showCtrlDialog() }
findViewById<Button>(R.id.esc).setOnClickListener { ssh.write(byteArrayOf(27)) }
findViewById<Button>(R.id.tab).setOnClickListener { ssh.write("\t") }
findViewById<Button>(R.id.up).setOnClickListener { ssh.write("\u001b[A") }
findViewById<Button>(R.id.down).setOnClickListener { ssh.write("\u001b[B") }
findViewById<Button>(R.id.left).setOnClickListener { ssh.write("\u001b[D") }
findViewById<Button>(R.id.right).setOnClickListener { ssh.write("\u001b[C") }
findViewById<Button>(R.id.settings).setOnClickListener { showSettings() }
}
private fun connect() {
val host = findViewById<EditText>(R.id.host).text.toString().trim()
val port = findViewById<EditText>(R.id.port).text.toString().toIntOrNull() ?: 22
val user = findViewById<EditText>(R.id.user).text.toString().trim()
val pass = findViewById<EditText>(R.id.password).text.toString()
if (host.isEmpty() || user.isEmpty()) { Toast.makeText(this,"Host dan username wajib diisi",Toast.LENGTH_SHORT).show(); return }
terminal.clear()
ssh.connect(host, port, user, pass, { data -> runOnUiThread { terminal.feed(data) } }, { s -> runOnUiThread { status.text = s } })
}
private fun showCtrlDialog() {
val letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
AlertDialog.Builder(this).setTitle("CTRL + …").setItems(letters.map { "Ctrl + $it" }.toTypedArray()) { _, which -> terminal.sendCtrl(letters[which]) }.show()
}
private fun showSettings() {
val size = SeekBar(this).apply { max = 32; progress = (terminal.fontSizePx - 8).toInt() }
val box = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setPadding(32,16,32,8); addView(TextView(this@MainActivity).apply { text = "Font size" }); addView(size) }
size.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(s: SeekBar?, p: Int, f: Boolean) { terminal.fontSizePx = (p + 8).toFloat() }; override fun onStartTrackingTouch(s: SeekBar?){}; override fun onStopTrackingTouch(s: SeekBar?){} })
AlertDialog.Builder(this).setTitle("Terminal Settings").setView(box).setPositiveButton("Close", null).setNeutralButton("GitHub Dark") { _, _ -> terminal.setTheme(Color.rgb(13,17,23), Color.rgb(230,237,243)) }.show()
}
override fun onDestroy() { ssh.close(); super.onDestroy() }
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:padding="16dp" android:layout_width="match_parent" android:layout_height="match_parent">
<TextView android:text="KosSSH Mobile" android:textSize="24sp" android:textStyle="bold" android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingBottom="12dp"/>
<EditText android:id="@+id/host" android:hint="Host / IP" android:inputType="textUri" android:layout_width="match_parent" android:layout_height="wrap_content"/>
<LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content">
<EditText android:id="@+id/port" android:hint="Port" android:text="22" android:inputType="number" android:layout_weight="1" android:layout_width="0dp" android:layout_height="wrap_content"/>
<EditText android:id="@+id/user" android:hint="Username" android:layout_weight="2" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="8dp"/>
</LinearLayout>
<EditText android:id="@+id/password" android:hint="Password" android:inputType="textPassword" android:layout_width="match_parent" android:layout_height="wrap_content"/>
<LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp"><Button android:id="@+id/connect" android:text="Connect" android:layout_weight="1" android:layout_width="0dp" android:layout_height="wrap_content"/><Button android:id="@+id/settings" android:text="Terminal Settings" android:layout_weight="1" android:layout_width="0dp" android:layout_height="wrap_content"/></LinearLayout>
<TextView android:id="@+id/status" android:text="Disconnected" android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingTop="8dp"/>
<com.bozz.kosssh.terminal.TerminalView android:id="@+id/terminal" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:layout_marginTop="8dp"/>
<HorizontalScrollView android:layout_width="match_parent" android:layout_height="wrap_content">
<LinearLayout android:orientation="horizontal" android:layout_width="wrap_content" android:layout_height="wrap_content">
<Button android:id="@+id/ctrl" android:text="CTRL" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
<Button android:id="@+id/esc" android:text="ESC" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
<Button android:id="@+id/tab" android:text="TAB" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
<Button android:id="@+id/up" android:text="↑" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
<Button android:id="@+id/down" android:text="↓" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
<Button android:id="@+id/left" android:text="←" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
<Button android:id="@+id/right" android:text="→" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
</LinearLayout>
</HorizontalScrollView>
</LinearLayout>

View File

@@ -0,0 +1 @@
<resources><color name="black">#000000</color><color name="white">#FFFFFF</color></resources>

View File

@@ -0,0 +1,6 @@
<resources>
<style name="Theme.KosSSHMobile" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:fontFamily">monospace</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>