original delivery
This commit is contained in:
16
app/build.gradle.kts
Normal file
16
app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
10
app/src/main/AndroidManifest.xml
Normal file
10
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
58
app/src/main/java/com/bozz/kosssh/ssh/SshClient.kt
Normal file
58
app/src/main/java/com/bozz/kosssh/ssh/SshClient.kt
Normal 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
|
||||
}
|
||||
}
|
||||
158
app/src/main/java/com/bozz/kosssh/terminal/TerminalView.kt
Normal file
158
app/src/main/java/com/bozz/kosssh/terminal/TerminalView.kt
Normal 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)) }
|
||||
}
|
||||
57
app/src/main/java/com/bozz/kosssh/ui/MainActivity.kt
Normal file
57
app/src/main/java/com/bozz/kosssh/ui/MainActivity.kt
Normal 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() }
|
||||
}
|
||||
24
app/src/main/res/layout/activity_main.xml
Normal file
24
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
1
app/src/main/res/values/colors.xml
Normal file
1
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1 @@
|
||||
<resources><color name="black">#000000</color><color name="white">#FFFFFF</color></resources>
|
||||
6
app/src/main/res/values/themes.xml
Normal file
6
app/src/main/res/values/themes.xml
Normal 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>
|
||||
Reference in New Issue
Block a user