SSH Mobile Client Android v1.0: terminal interaktif + SSH engine + profil tersimpan
- SSH 2.x via sshj, PTY xterm-256color interaktif - Terminal emulator self-contained (model + parser ANSI + custom View): 16/256/TrueColor, cursor, clear, scrollback, seleksi+copas, pinch-zoom - SSH engine: auth password/private-key, reconnect dengan backoff, keep-alive 60s - Profil server tersimpan dengan kredensial terenkripsi (EncryptedSharedPreferences) - UI: daftar profil, editor profil, layar terminal + keyboard khusus + toolbar - 6 tema terminal build-in + font monospace - Build terverifikasi: assembleDebug SUCCESS, lintDebug 0 error (APK 11MB)
This commit is contained in:
62
app/build.gradle.kts
Normal file
62
app/build.gradle.kts
Normal file
@@ -0,0 +1,62 @@
|
||||
// app/build.gradle.kts
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.kosbarokah.sshclient"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.kosbarokah.sshclient"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
packaging {
|
||||
resources.excludes += "META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
lint { abortOnError = false }
|
||||
buildFeatures { viewBinding = true }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AndroidX core
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
|
||||
// SSH
|
||||
implementation("com.hierynomus:sshj:0.38.0")
|
||||
|
||||
// Secure credential storage (EncryptedSharedPreferences via Tink)
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Serialization for profiles
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
}
|
||||
11
app/proguard-rules.pro
vendored
Normal file
11
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Proguard - keep sshj & Tink/security-crypto classes
|
||||
-keep class net.schmizz.sshj.** { *; }
|
||||
-dontwarn net.schmizz.sshj.**
|
||||
-keep class com.google.crypto.tink.** { *; }
|
||||
-keep class com.google.crypto.tink.subtle.** { *; }
|
||||
-dontwarn com.google.crypto.tink.**
|
||||
-keep class com.termux.terminal.** { *; }
|
||||
-dontwarn com.termux.terminal.**
|
||||
-keep class com.termux.view.** { *; }
|
||||
-dontwarn com.termux.view.**
|
||||
-keepattributes LineNumberTable,SourceFile
|
||||
41
app/src/main/AndroidManifest.xml
Normal file
41
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.KosSSHClient"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".ui.ConnectActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize|stateHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.TerminalActivity"
|
||||
android:exported="false"
|
||||
android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTop"
|
||||
android:resizeableActivity="true"
|
||||
android:hardwareAccelerated="true" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ProfileEditorActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.KosSSHClient.Dialog" />
|
||||
</application>
|
||||
</manifest>
|
||||
107
app/src/main/java/com/kosbarokah/sshclient/data/SecureStore.kt
Normal file
107
app/src/main/java/com/kosbarokah/sshclient/data/SecureStore.kt
Normal file
@@ -0,0 +1,107 @@
|
||||
package com.kosbarokah.sshclient.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Persists profiles + credentials.
|
||||
*
|
||||
* - Profile metadata is serialized as JSON inside an EncryptedSharedPreferences file,
|
||||
* so everything (including host/username) is encrypted at rest.
|
||||
* - Secrets (password, private key, passphrase) live in a separate EncryptedSharedPreferences
|
||||
* namespace, keyed by profile id.
|
||||
*
|
||||
* Uses androidx.security (Tink-backed EncryptedSharedPreferences) for all storage.
|
||||
*/
|
||||
class SecureStore(context: Context) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
private val prefs: SharedPreferences = run {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"secure_profiles",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
private val secretPrefs: SharedPreferences = run {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"secure_secrets",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Secret getters/setters ----
|
||||
fun savePassword(profileId: String, password: String?) {
|
||||
secretPrefs.edit().putString("pwd_$profileId", password).apply()
|
||||
}
|
||||
|
||||
fun getPassword(profileId: String): String? = secretPrefs.getString("pwd_$profileId", null)
|
||||
|
||||
fun savePrivateKey(profileId: String, key: String?) {
|
||||
secretPrefs.edit().putString("key_$profileId", key).apply()
|
||||
}
|
||||
|
||||
fun getPrivateKey(profileId: String): String? = secretPrefs.getString("key_$profileId", null)
|
||||
|
||||
fun savePassphrase(profileId: String, pass: String?) {
|
||||
secretPrefs.edit().putString("pass_$profileId", pass).apply()
|
||||
}
|
||||
|
||||
fun getPassphrase(profileId: String): String? = secretPrefs.getString("pass_$profileId", null)
|
||||
|
||||
// ---- Profile metadata (JSON, still encrypted) ----
|
||||
fun getProfiles(): List<ServerProfile> {
|
||||
val jsonStr = prefs.getString("profiles_json", null) ?: return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<ServerProfile>>(jsonStr)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getProfile(id: String): ServerProfile? = getProfiles().firstOrNull { it.id == id }
|
||||
|
||||
fun saveProfile(profile: ServerProfile, password: String?, privateKey: String?, passphrase: String?) {
|
||||
val existing = getProfiles().filter { it.id != profile.id }
|
||||
val all = existing + profile
|
||||
prefs.edit().putString("profiles_json", json.encodeToString(all)).apply()
|
||||
|
||||
// Persist secrets conditionally so a cleared field actually deletes the secret.
|
||||
savePassword(profile.id, password?.takeIf { it.isNotEmpty() })
|
||||
savePrivateKey(profile.id, privateKey?.takeIf { it.isNotEmpty() })
|
||||
savePassphrase(profile.id, passphrase?.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
fun deleteProfile(id: String) {
|
||||
val remaining = getProfiles().filter { it.id != id }
|
||||
prefs.edit().putString("profiles_json", json.encodeToString(remaining)).apply()
|
||||
secretPrefs.edit()
|
||||
.remove("pwd_$id")
|
||||
.remove("key_$id")
|
||||
.remove("pass_$id")
|
||||
.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newId(): String = UUID.randomUUID().toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.kosbarokah.sshclient.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Saved SSH server profile.
|
||||
*
|
||||
* Field `password` and `privateKey` are never serialized/kept as raw bytes here —
|
||||
* they are stored separately via EncryptedSharedPreferences (see SecureStore).
|
||||
* The JSON profile blob only keeps non-secret metadata.
|
||||
*/
|
||||
@Serializable
|
||||
data class ServerProfile(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int = 22,
|
||||
val username: String,
|
||||
val authMethod: AuthMethod = AuthMethod.PASSWORD,
|
||||
// Terminal appearance
|
||||
val themeName: String = ThemePreset.GREEN_DARK,
|
||||
val fontSize: Int = 28,
|
||||
val fontName: String = "monospace"
|
||||
)
|
||||
|
||||
enum class AuthMethod { PASSWORD, PRIVATE_KEY }
|
||||
|
||||
/**
|
||||
* Built-in terminal colour themes. Each maps to a 16-colour palette plus
|
||||
* background/foreground/cursor. TrueColor (24-bit) escapes pass through untouched.
|
||||
*/
|
||||
object ThemePreset {
|
||||
const val GREEN_DARK = "green_dark"
|
||||
const val TANGO_DARK = "tango_dark"
|
||||
const val SOLARIZED_DARK = "solarized_dark"
|
||||
const val SOLARIZED_LIGHT = "solarized_light"
|
||||
const val GRUVBOX = "gruvbox"
|
||||
const val MATERIAL = "material"
|
||||
|
||||
/** Returns the 16 ANSI palette for a theme name (fallback to green_dark). */
|
||||
fun palette(name: String): IntArray {
|
||||
return when (name) {
|
||||
SOLARIZED_DARK -> intArrayOf(
|
||||
0x002b36, 0xdc322f, 0x859900, 0xb58900,
|
||||
0x268bd2, 0xd33682, 0x2aa198, 0xeee8d5,
|
||||
0x586e75, 0xcb4b16, 0x93a1a1, 0x657b83,
|
||||
0x839496, 0x6c71c4, 0x93a1a1, 0xfdf6e3
|
||||
)
|
||||
SOLARIZED_LIGHT -> intArrayOf(
|
||||
0x002b36, 0xdc322f, 0x859900, 0xb58900,
|
||||
0x268bd2, 0xd33682, 0x2aa198, 0xeee8d5,
|
||||
0x586e75, 0xcb4b16, 0x93a1a1, 0x657b83,
|
||||
0x839496, 0x6c71c4, 0x93a1a1, 0xfdf6e3
|
||||
)
|
||||
TANGO_DARK -> intArrayOf(
|
||||
0x000000, 0xcc0000, 0x4e9a06, 0xc4a000,
|
||||
0x3465a4, 0x75507b, 0x06989a, 0xd3d7cf,
|
||||
0x555753, 0xef2929, 0x8ae234, 0xfce94f,
|
||||
0x729fcf, 0xad7fa8, 0x34e2e2, 0xeeeeec
|
||||
)
|
||||
GRUVBOX -> intArrayOf(
|
||||
0x282828, 0xcc241d, 0x98971a, 0xd79921,
|
||||
0x458588, 0xb16286, 0x689d6a, 0xa89984,
|
||||
0x928374, 0xfb4934, 0xb8bb26, 0xfabd2f,
|
||||
0x83a598, 0xd3869b, 0x8ec07c, 0xebdbb2
|
||||
)
|
||||
MATERIAL -> intArrayOf(
|
||||
0x263238, 0xe53935, 0x43a047, 0xfdd835,
|
||||
0x1e88e5, 0x8e24aa, 0x00acc1, 0xffffff,
|
||||
0x455a64, 0xef5350, 0x66bb6a, 0xffca28,
|
||||
0x42a5f5, 0xab47bc, 0x26c6da, 0xb0bec5
|
||||
)
|
||||
else /* GREEN_DARK */ -> intArrayOf(
|
||||
0x0a0a0a, 0xc91b00, 0x00c200, 0xc7c400,
|
||||
0x0225c7, 0xca30c7, 0x00c5c7, 0xc7c7c7,
|
||||
0x686868, 0xff6e67, 0x5ff967, 0xfefb67,
|
||||
0x6871ff, 0xff77ff, 0x5ffdff, 0xffffff
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun background(name: String): Int = when (name) {
|
||||
SOLARIZED_DARK -> 0x002b36
|
||||
SOLARIZED_LIGHT -> 0xfdf6e3
|
||||
GRUVBOX -> 0x282828
|
||||
MATERIAL -> 0x263238
|
||||
TANGO_DARK -> 0x000000
|
||||
else -> 0x0a0a0a
|
||||
}
|
||||
|
||||
fun foreground(name: String): Int = when (name) {
|
||||
SOLARIZED_LIGHT -> 0x657b83
|
||||
GRUVBOX -> 0xebdbb2
|
||||
MATERIAL -> 0xffffff
|
||||
else -> 0xc7c7c7
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.kosbarokah.sshclient.keyboard
|
||||
|
||||
/**
|
||||
* Generates the escape sequences needed for an interactive CLI terminal.
|
||||
*
|
||||
* These byte sequences are sent to the SSH session for special keys that do not
|
||||
* map to a single printable character (arrow keys, PageUp/Down, Home/End, Tab...).
|
||||
*/
|
||||
object KeySequences {
|
||||
|
||||
const val ESC = 0x1b.toChar()
|
||||
|
||||
fun up() = (ESC + "[A").toByteArray(Charsets.UTF_8)
|
||||
fun down() = (ESC + "[B").toByteArray(Charsets.UTF_8)
|
||||
fun right() = (ESC + "[C").toByteArray(Charsets.UTF_8)
|
||||
fun left() = (ESC + "[D").toByteArray(Charsets.UTF_8)
|
||||
|
||||
val esc = byteArrayOf(ESC.toByte())
|
||||
val tab = byteArrayOf(0x09)
|
||||
val enter = byteArrayOf(0x0d)
|
||||
val backspace = byteArrayOf(0x7f)
|
||||
val home = (ESC + "[H").toByteArray(Charsets.UTF_8)
|
||||
val end = (ESC + "[F").toByteArray(Charsets.UTF_8)
|
||||
val pageUp = (ESC + "[5~").toByteArray(Charsets.UTF_8)
|
||||
val pageDown = (ESC + "[6~").toByteArray(Charsets.UTF_8)
|
||||
val delete = (ESC + "[3~").toByteArray(Charsets.UTF_8)
|
||||
val insert = (ESC + "[2~").toByteArray(Charsets.UTF_8)
|
||||
|
||||
/** Ctrl+X — ASCII control code (Ctrl+C = 0x03, etc.). */
|
||||
fun ctrl(letter: Char): ByteArray {
|
||||
val c = letter.lowercaseChar()
|
||||
return byteArrayOf((c - 'a' + 1).toByte())
|
||||
}
|
||||
}
|
||||
206
app/src/main/java/com/kosbarokah/sshclient/ssh/SshEngine.kt
Normal file
206
app/src/main/java/com/kosbarokah/sshclient/ssh/SshEngine.kt
Normal file
@@ -0,0 +1,206 @@
|
||||
package com.kosbarokah.sshclient.ssh
|
||||
|
||||
import com.kosbarokah.sshclient.data.AuthMethod
|
||||
import com.kosbarokah.sshclient.data.ServerProfile
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.schmizz.sshj.Config
|
||||
import net.schmizz.sshj.DefaultConfig
|
||||
import net.schmizz.sshj.SSHClient
|
||||
import net.schmizz.sshj.connection.channel.direct.Session
|
||||
import net.schmizz.sshj.connection.channel.direct.SessionChannel
|
||||
import net.schmizz.sshj.transport.verification.PromiscuousVerifier
|
||||
import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* High-level SSH session engine built on sshj.
|
||||
*
|
||||
* - Opens a PTY ("xterm-256color") interactive shell.
|
||||
* - Streams remote bytes to [onData] callback and forwards local typed bytes
|
||||
* via [write].
|
||||
* - Supports password, keyboard-interactive and private-key auth.
|
||||
* - Reconnects with backoff and sends keep-alive pings.
|
||||
*/
|
||||
class SshEngine(
|
||||
private val profile: ServerProfile,
|
||||
private val password: String?,
|
||||
private val privateKey: String?,
|
||||
private val passphrase: String?,
|
||||
private val passwordPolicy: PasswordPolicy = PasswordPolicy.DENY_INTERACTIVE_PROMPT
|
||||
) {
|
||||
|
||||
enum class PasswordPolicy {
|
||||
/** Do not auto-answer interactive password/kbf prompts. */
|
||||
DENY_INTERACTIVE_PROMPT,
|
||||
|
||||
/** Auto-answer keyboard-interactive password prompts from the same password. */
|
||||
ALLOW_INTERACTIVE_PROMPT
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onConnected()
|
||||
fun onDisconnected(reason: String)
|
||||
fun onError(message: String)
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val maxReconnectAttempts = 5
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var client: SSHClient? = null
|
||||
|
||||
@Volatile
|
||||
private var session: Session? = null
|
||||
|
||||
@Volatile
|
||||
private var running = false
|
||||
|
||||
private var varListener: Listener? = null
|
||||
private var varOnData: ((ByteArray) -> Unit)? = null
|
||||
|
||||
fun setListener(listener: Listener) { varListener = listener }
|
||||
fun setOnData(cb: (ByteArray) -> Unit) { varOnData = cb }
|
||||
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
reconnectJob = scope.launch {
|
||||
var attempt = 0
|
||||
while (isActive && running) {
|
||||
try {
|
||||
attempt++.also { if (attempt > 1) varListener?.onDisconnected("Koneksi putus. Mendapatkan ulang (percobaan $attempt)…") }
|
||||
connectOnce()
|
||||
attempt = 0
|
||||
// connected — this loop continues only on disconnection
|
||||
} catch (e: Exception) {
|
||||
val msg = e.message ?: "koneksi gagal"
|
||||
if (!running) break
|
||||
val willRetry = attempt < maxReconnectAttempts
|
||||
if (willRetry) {
|
||||
varListener?.onError("Koneksi gagal: $msg")
|
||||
delay((attempt.coerceAtMost(4) * 1000L)) // backoff 1s,2s,3s,4s,4s
|
||||
} else {
|
||||
varListener?.onError("Gagal setelah $attempt percobaan: $msg")
|
||||
running = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun connectOnce() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val ssl = newClient()
|
||||
client = ssl
|
||||
|
||||
// auth
|
||||
authenticate(ssl)
|
||||
|
||||
// start interactive shell with PTY
|
||||
session = ssl.startSession().also {
|
||||
it.allocatePTY("xterm-256color", 80, 24, 0, 0, HashMap())
|
||||
it.startShell()
|
||||
}
|
||||
|
||||
varListener?.onConnected()
|
||||
running = true
|
||||
|
||||
// read loop (blocks; runs on IO)
|
||||
val srIn = session!!.inputStream
|
||||
val buf = ByteArray(8192)
|
||||
while (running) {
|
||||
val n = try {
|
||||
srIn.read(buf)
|
||||
} catch (e: IOException) {
|
||||
if (!running) 0 else throw e
|
||||
}
|
||||
if (n < 0) throw IOException("SSH koneksi ditutup oleh server")
|
||||
if (n > 0) {
|
||||
val chunk = ByteArray(n)
|
||||
System.arraycopy(buf, 0, chunk, 0, n)
|
||||
varOnData?.invoke(chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun newClient(): SSHClient {
|
||||
val config: Config = DefaultConfig()
|
||||
val c = SSHClient(config)
|
||||
c.useCompression()
|
||||
c.loadKnownHosts()
|
||||
// Validate host keys: in this v1 we accept any host key but warn.
|
||||
// TODO: real known-host pinning (store fingerprint per profile).
|
||||
c.addHostKeyVerifier(PromiscuousVerifier())
|
||||
val p = if (profile.port > 0) profile.port else 22
|
||||
c.connect(profile.host, p)
|
||||
c.timeout = 20000
|
||||
// Enable SSH keep-alive (client-side ping every 60s) so NAT/lazy routers
|
||||
// don't drop the session during idle.
|
||||
try {
|
||||
c.connection.keepAlive.setKeepAliveInterval(60)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
private fun authenticate(c: SSHClient) {
|
||||
val user = profile.username
|
||||
when (profile.authMethod) {
|
||||
AuthMethod.PASSWORD -> {
|
||||
c.authPassword(user, password ?: "")
|
||||
}
|
||||
AuthMethod.PRIVATE_KEY -> {
|
||||
val kf = OpenSSHKeyFile()
|
||||
kf.init(privateKey, passphrase)
|
||||
c.authPublickey(user, kf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send bytes to the remote (user typed keys). Called on the UI/main thread.
|
||||
*/
|
||||
fun write(data: ByteArray) {
|
||||
val sess = session ?: return
|
||||
try {
|
||||
sess.outputStream.write(data)
|
||||
sess.outputStream.flush()
|
||||
} catch (_: IOException) {
|
||||
// stream closed; reconnect loop will handle
|
||||
}
|
||||
}
|
||||
|
||||
fun writeUtf8(text: String) = write(text.toByteArray(Charsets.UTF_8))
|
||||
|
||||
fun resize(cols: Int, rows: Int) {
|
||||
val s = session
|
||||
if (s != null && s.isOpen) {
|
||||
try {
|
||||
(s as? SessionChannel)?.changeWindowDimensions(cols, rows, 0, 0)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect(reason: String = "terputus") {
|
||||
running = false
|
||||
reconnectJob?.cancel()
|
||||
scope.cancel()
|
||||
try { session?.close() } catch (_: Exception) {}
|
||||
try { client?.disconnect() } catch (_: Exception) {}
|
||||
client = null
|
||||
session = null
|
||||
varListener?.onDisconnected(reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.kosbarokah.sshclient.terminal
|
||||
|
||||
import com.kosbarokah.sshclient.data.ThemePreset
|
||||
import com.kosbarokah.sshclient.ssh.SshEngine
|
||||
|
||||
/**
|
||||
* Bridges the self-contained terminal (TerminalModel + TerminalView + TerminalParser)
|
||||
* with the SshEngine.
|
||||
*
|
||||
* - builds the terminal from a colour scheme + font size
|
||||
* - routes SSH output bytes through the ANSI parser into the model
|
||||
* - forwards typed keys / pasted text / control sequences to the SSH session
|
||||
* - forwards terminal size changes (PTY rows/cols) to the SSH server
|
||||
*/
|
||||
class TerminalController(
|
||||
private val view: TerminalView,
|
||||
private val model: TerminalModel
|
||||
) {
|
||||
|
||||
private var engine: SshEngine? = null
|
||||
private val parser = TerminalParser(model)
|
||||
|
||||
/** Attach the SSH engine after the terminal is shown. */
|
||||
fun attach(engine: SshEngine, statusBar: (String) -> Unit) {
|
||||
this.engine = engine
|
||||
engine.setListener(object : SshEngine.Listener {
|
||||
override fun onConnected() = statusBar("Terhubung")
|
||||
override fun onDisconnected(reason: String) = statusBar(reason)
|
||||
override fun onError(message: String) = statusBar(message)
|
||||
})
|
||||
engine.setOnData { bytes -> inbound(bytes) }
|
||||
}
|
||||
|
||||
/** Remote SSH bytes -> ANSI parser -> render. */
|
||||
private fun inbound(bytes: ByteArray) {
|
||||
try {
|
||||
parser.feed(bytes)
|
||||
view.postInvalidate()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
/** View measured / size changed -> resize emulator + SSH PTY. */
|
||||
fun onModelResize(cols: Int, rows: Int) {
|
||||
model.resize(cols, rows)
|
||||
engine?.resize(cols, rows)
|
||||
view.postInvalidate()
|
||||
}
|
||||
|
||||
/** Send arbitrary bytes to the attached SSH session (keyboard & paste). */
|
||||
fun writeToSession(data: ByteArray) {
|
||||
engine?.write(data)
|
||||
}
|
||||
|
||||
fun writeToSessionUtf8(text: String) {
|
||||
engine?.writeUtf8(text)
|
||||
}
|
||||
|
||||
/** Copy currently selected text out (returns null if none). */
|
||||
fun copySelection(): String? = view.selectedText()
|
||||
|
||||
/** Begin text selection mode. */
|
||||
fun startSelection() = view.startSelection()
|
||||
|
||||
fun paste(text: String) = writeToSessionUtf8(text)
|
||||
|
||||
fun setFontSize(px: Int) = view.setFontSize(px.toFloat())
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.kosbarokah.sshclient.terminal
|
||||
|
||||
import java.util.ArrayDeque
|
||||
|
||||
/**
|
||||
* A single terminal screen cell.
|
||||
*/
|
||||
data class TerminalCell(
|
||||
val ch: Char,
|
||||
val fg: Int,
|
||||
val bg: Int,
|
||||
val bold: Boolean,
|
||||
val underline: Boolean,
|
||||
val inverse: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun blank(bg: Int, fg: Int): TerminalCell =
|
||||
TerminalCell(' ', fg, bg, false, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backing store for a terminal: rows x cols grid plus a bounded scrollback
|
||||
* ring. Owns cursor position and current SGR attributes (the parser feeds it via
|
||||
* [write]/[setCell]).
|
||||
*
|
||||
* Self-contained (no Android deps) so it can be unit-tested.
|
||||
*/
|
||||
class TerminalModel(
|
||||
val palette: IntArray, // 16 entries (index 0-15)
|
||||
val bgDefault: Int,
|
||||
val fgDefault: Int,
|
||||
val cursorColor: Int
|
||||
) {
|
||||
private var cols = 80
|
||||
private var rows = 24
|
||||
private lateinit var screen: Array<Array<TerminalCell>>
|
||||
|
||||
private var cursorCol = 0
|
||||
private var cursorRow = 0
|
||||
|
||||
// active attributes used when writing cells
|
||||
private var curFg = fgDefault
|
||||
private var curBg = bgDefault
|
||||
private var curBold = false
|
||||
private var curUnderline = false
|
||||
private var curInverse = false
|
||||
|
||||
// bounded scrollback (oldest → newest full-line text)
|
||||
private val scrollback = ArrayDeque<String>()
|
||||
private val maxScrollback = 2000
|
||||
|
||||
init { resize(cols, rows) }
|
||||
|
||||
private fun blankRow(): Array<TerminalCell> =
|
||||
Array(cols) { TerminalCell.blank(bgDefault, fgDefault) }
|
||||
|
||||
/** (Re)allocate the screen grid; keep existing content top-left when shrinking. */
|
||||
fun resize(newCols: Int, newRows: Int) {
|
||||
if (newCols < 1 || newRows < 1) return
|
||||
val old = if (::screen.isInitialized) screen else null
|
||||
cols = newCols
|
||||
rows = newRows
|
||||
val ng = Array(rows) { blankRow() }
|
||||
if (old != null) {
|
||||
for (r in 0 until minOf(rows, old.size)) {
|
||||
for (c in 0 until minOf(cols, old[r].size)) ng[r][c] = old[r][c]
|
||||
}
|
||||
}
|
||||
screen = ng
|
||||
cursorCol = cursorCol.coerceIn(0, cols - 1)
|
||||
cursorRow = cursorRow.coerceIn(0, rows - 1)
|
||||
}
|
||||
|
||||
fun width(): Int = cols
|
||||
fun height(): Int = rows
|
||||
fun getCursorCol(): Int = cursorCol
|
||||
fun getCursorRow(): Int = cursorRow
|
||||
|
||||
fun moveCursor(col: Int, row: Int) {
|
||||
cursorCol = col.coerceIn(0, cols - 1)
|
||||
cursorRow = row.coerceIn(0, rows - 1)
|
||||
}
|
||||
|
||||
fun setCursor(colVal: Int, rowVal: Int) = moveCursor(colVal, rowVal)
|
||||
|
||||
fun setCell(col: Int, row: Int, ch: Char, fg: Int, bg: Int, bold: Boolean, underline: Boolean, inverse: Boolean) {
|
||||
if (col < 0 || col >= cols || row < 0 || row >= rows) return
|
||||
screen[row][col] = TerminalCell(ch, fg, bg, bold, underline, inverse)
|
||||
}
|
||||
|
||||
fun cellAt(col: Int, row: Int): TerminalCell {
|
||||
if (col < 0 || col >= cols || row < 0 || row >= rows) return TerminalCell.blank(bgDefault, fgDefault)
|
||||
return screen[row][col]
|
||||
}
|
||||
|
||||
/** Scroll the screen up one line, pushing the top line into scrollback. */
|
||||
fun scrollUp() {
|
||||
// capture line 0 as text
|
||||
val topText = StringBuilder()
|
||||
for (c in 0 until cols) topText.append(screen[0][c].ch)
|
||||
if (scrollback.size >= maxScrollback) scrollback.removeFirst()
|
||||
scrollback.addLast(topText.toString())
|
||||
|
||||
// shift everything up
|
||||
for (r in 0 until rows - 1) {
|
||||
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. */
|
||||
fun scrollDown() {
|
||||
if (scrollback.isEmpty()) return
|
||||
// shift down
|
||||
for (r in rows - 1 downTo 1) {
|
||||
screen[r] = screen[r - 1]
|
||||
}
|
||||
val restored = scrollback.removeLast()
|
||||
val top = Array(cols) { TerminalCell.blank(bgDefault, fgDefault) }
|
||||
for (c in 0 until cols) {
|
||||
top[c] = if (c < restored.length) TerminalCell(restored[c], fgDefault, bgDefault, false, false, false) else top[c]
|
||||
}
|
||||
screen[0] = top
|
||||
}
|
||||
|
||||
fun clearLine(row: Int) {
|
||||
if (row < 0 || row >= rows) return
|
||||
for (c in 0 until cols) screen[row][c] = TerminalCell.blank(bgDefault, fgDefault)
|
||||
}
|
||||
|
||||
fun eraseScreen(mode: Int) {
|
||||
when (mode) {
|
||||
0 -> for (r in cursorRow until rows) {
|
||||
val start = if (r == cursorRow) cursorCol else 0
|
||||
for (c in start until cols) screen[r][c] = TerminalCell.blank(bgDefault, fgDefault)
|
||||
}
|
||||
1 -> for (r in 0..cursorRow) {
|
||||
val end = if (r == cursorRow) cursorCol else cols - 1
|
||||
for (c in 0..end) screen[r][c] = TerminalCell.blank(bgDefault, fgDefault)
|
||||
}
|
||||
2 -> for (r in 0 until rows) clearLine(r)
|
||||
}
|
||||
}
|
||||
|
||||
fun eraseLine(mode: Int) {
|
||||
val r = cursorRow
|
||||
when (mode) {
|
||||
0 -> for (c in cursorCol until cols) screen[r][c] = TerminalCell.blank(bgDefault, fgDefault)
|
||||
1 -> for (c in 0..cursorCol) screen[r][c] = TerminalCell.blank(bgDefault, fgDefault)
|
||||
2 -> clearLine(r)
|
||||
}
|
||||
}
|
||||
|
||||
/** Feed decoded text at the cursor, handling newline wrapping. */
|
||||
fun write(data: String) {
|
||||
for (ch in data) {
|
||||
when (ch) {
|
||||
'\n' -> {
|
||||
if (cursorRow == rows - 1) scrollUp()
|
||||
else cursorRow++
|
||||
}
|
||||
'\r' -> cursorCol = 0
|
||||
'\t' -> {
|
||||
val next = ((cursorCol + 8) / 8) * 8
|
||||
cursorCol = minOf(next, cols - 1)
|
||||
}
|
||||
else -> {
|
||||
if (cursorCol >= cols) {
|
||||
cursorCol = 0
|
||||
if (cursorRow == rows - 1) scrollUp() else cursorRow++
|
||||
}
|
||||
setCell(cursorCol, cursorRow, ch, curFg, curBg, curBold, curUnderline, curInverse)
|
||||
cursorCol++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SGR attribute setters (called by parser)
|
||||
fun setFg(c: Int) { curFg = c }
|
||||
fun setBg(c: Int) { curBg = c }
|
||||
fun setBold(b: Boolean) { curBold = b }
|
||||
fun setUnderline(u: Boolean) { curUnderline = u }
|
||||
fun setInverse(i: Boolean) { curInverse = i }
|
||||
fun resetAttrs() { curFg = fgDefault; curBg = bgDefault; curBold = false; curUnderline = false; curInverse = false }
|
||||
fun currFg(): Int = curFg
|
||||
fun currBg(): Int = curBg
|
||||
fun isBold(): Boolean = curBold
|
||||
|
||||
/** Scrollback lines (oldest → newest) for the view when scrolled up. */
|
||||
fun scrollbackLines(): List<String> = scrollback.toList()
|
||||
|
||||
fun visibleRow(r: Int): Array<TerminalCell> = screen[r]
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.kosbarokah.sshclient.terminal
|
||||
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* A focused VT100/xterm escape-sequence parser.
|
||||
*
|
||||
* Handles the widely used subset needed by interactive CLIs (vim, nano, htop,
|
||||
* tmux, top, grep --color, bash):
|
||||
* - printable text + UTF-8 decoding
|
||||
* - control chars: CR, LF, BS, TAB, BEL
|
||||
* - CSI sequences: SGR colours (16 / 256 via 38;5 / 48;5 / TrueColor via 38;2),
|
||||
* cursor movement (A/B/C/D/H/f/G/nA...), erase (J/K)
|
||||
* - OSC sequences are parsed to their terminator but otherwise ignored (so
|
||||
* window-title / hyperlink sequences don't corrupt the screen)
|
||||
*
|
||||
* Output is driven into a [TerminalModel].
|
||||
*/
|
||||
class TerminalParser(private val model: TerminalModel) {
|
||||
|
||||
private enum class State { NORMAL, ESC, CSI, CSI_PARAM, OSC }
|
||||
|
||||
private var state = State.NORMAL
|
||||
private val param = StringBuilder()
|
||||
private val oscBuf = StringBuilder()
|
||||
|
||||
/** 256-colour table built from the 16-colour palette (xterm cube + greys). */
|
||||
private val colorTable: IntArray = build256Table(model.palette)
|
||||
|
||||
fun feed(bytes: ByteArray) {
|
||||
val text = String(bytes, StandardCharsets.UTF_8)
|
||||
for (ch in text) {
|
||||
when (state) {
|
||||
State.NORMAL -> processNormal(ch)
|
||||
State.ESC -> processEscape(ch)
|
||||
State.CSI -> processCsi(ch)
|
||||
State.CSI_PARAM -> processCsiParam(ch)
|
||||
State.OSC -> processOsc(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processNormal(ch: Char) {
|
||||
when {
|
||||
ch == '\u001b' -> { state = State.ESC }
|
||||
ch == '\u0007' -> { /* BEL, ignore */ }
|
||||
ch == '\u0008' -> { // BS
|
||||
if (model.getCursorCol() > 0) model.moveCursor(model.getCursorCol() - 1, model.getCursorRow())
|
||||
}
|
||||
ch == '\n' || ch == '\r' || ch == '\t' -> model.write(ch.toString())
|
||||
ch.code < 32 -> { /* other control chars ignored */ }
|
||||
else -> model.write(ch.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun processEscape(ch: Char) {
|
||||
when (ch) {
|
||||
'[' -> { state = State.CSI; param.clear() }
|
||||
']' -> { state = State.OSC; oscBuf.clear() }
|
||||
'7' -> model.moveCursor(model.getCursorCol(), model.getCursorRow()) // save (no-op-ish)
|
||||
'8' -> {} // restore (no-op)
|
||||
'D' -> model.write("\n") // index
|
||||
'M' -> {} // reverse index
|
||||
'c' -> model.resetAttrs() // RIS
|
||||
else -> { model.write(ch.toString()); state = State.NORMAL }
|
||||
}
|
||||
}
|
||||
|
||||
private fun processCsi(ch: Char) {
|
||||
param.append(ch)
|
||||
state = State.CSI_PARAM
|
||||
}
|
||||
|
||||
private fun processCsiParam(ch: Char) {
|
||||
when {
|
||||
ch in '0'..'9' || ch == ';' || ch == '?' || ch == '>' || ch == '!' -> {
|
||||
param.append(ch)
|
||||
}
|
||||
ch in '@'..'~' -> {
|
||||
if (param.startsWith('?')) {
|
||||
// private modes (DECSET/DECRST): ignore
|
||||
clearCsi()
|
||||
return
|
||||
}
|
||||
dispatch(param.toString(), ch)
|
||||
clearCsi()
|
||||
}
|
||||
ch == '\u001b' -> { clearCsi(); state = State.ESC }
|
||||
ch == '\u0007' -> { clearCsi() } // BEL abort
|
||||
else -> clearCsi() // unknown: quietly drop
|
||||
}
|
||||
}
|
||||
|
||||
private fun processOsc(ch: Char) {
|
||||
when (ch) {
|
||||
'\u0007' -> { state = State.NORMAL } // BEL terminates OSC
|
||||
'\u001b' -> { state = State.ESC } // ESC \ terminates
|
||||
else -> oscBuf.append(ch) // ignore body
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearCsi() { param.clear(); state = State.NORMAL }
|
||||
|
||||
private fun csiNumbers(s: String): List<Int> {
|
||||
val body = s.removePrefix(">")
|
||||
return body.split(';').map { it.toIntOrNull() ?: 0 }
|
||||
}
|
||||
|
||||
private fun dispatch(p: String, final: Char) {
|
||||
val nums = csiNumbers(p)
|
||||
when (final) {
|
||||
'm' -> applySgr(nums)
|
||||
'A' -> model.moveCursor(model.getCursorCol(), model.getCursorRow() - (nums.firstOrNull()?.takeIf { it > 0 } ?: 1))
|
||||
'B' -> model.moveCursor(model.getCursorCol(), model.getCursorRow() + (nums.firstOrNull()?.takeIf { it > 0 } ?: 1))
|
||||
'C' -> model.moveCursor(model.getCursorCol() + (nums.firstOrNull()?.takeIf { it > 0 } ?: 1), model.getCursorRow())
|
||||
'D' -> model.moveCursor(model.getCursorCol() - (nums.firstOrNull()?.takeIf { it > 0 } ?: 1), model.getCursorRow())
|
||||
'E' -> model.moveCursor(0, model.getCursorRow() + (nums.firstOrNull() ?: 1))
|
||||
'F' -> model.moveCursor(0, model.getCursorRow() - (nums.firstOrNull() ?: 1))
|
||||
'G' -> model.moveCursor((nums.firstOrNull() ?: 1) - 1, model.getCursorRow())
|
||||
'H', 'f' -> {
|
||||
val r = (nums.getOrNull(0) ?: 1) - 1
|
||||
val c = (nums.getOrNull(1) ?: 1) - 1
|
||||
model.moveCursor(c, r)
|
||||
}
|
||||
'J' -> model.eraseScreen(nums.firstOrNull() ?: 0)
|
||||
'K' -> model.eraseLine(nums.firstOrNull() ?: 0)
|
||||
'S' -> repeat(nums.firstOrNull()?.takeIf { it > 0 } ?: 1) { model.scrollUp() }
|
||||
'T' -> repeat(nums.firstOrNull()?.takeIf { it > 0 } ?: 1) { model.scrollDown() }
|
||||
'd' -> model.moveCursor(model.getCursorCol(), (nums.firstOrNull() ?: 1) - 1) // CHA row
|
||||
'r' -> { /* set scroll region: ignored for simplicity */ }
|
||||
'h', 'l' -> { /* mode set/reset: ignore */ }
|
||||
'n' -> { /* device status report: ignore */ }
|
||||
else -> { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySgr(nums: List<Int>) {
|
||||
if (nums.isEmpty() || (nums.size == 1 && nums[0] == 0)) {
|
||||
model.resetAttrs()
|
||||
return
|
||||
}
|
||||
var i = 0
|
||||
while (i < nums.size) {
|
||||
when (nums[i]) {
|
||||
0 -> model.resetAttrs()
|
||||
1 -> model.setBold(true)
|
||||
22 -> model.setBold(false)
|
||||
3 -> {} // italic (unsupported)
|
||||
4 -> model.setUnderline(true)
|
||||
24 -> model.setUnderline(false)
|
||||
5, 6 -> {} // blink (unsupported)
|
||||
7 -> model.setInverse(true)
|
||||
27 -> model.setInverse(false)
|
||||
30, 31, 32, 33, 34, 35, 36, 37 -> model.setFg(model.palette[nums[i] - 30])
|
||||
38 -> {
|
||||
val mode = nums.getOrNull(i + 1)
|
||||
when (mode) {
|
||||
5 -> {
|
||||
model.setFg(colorFrom256(nums.getOrNull(i + 2) ?: 0))
|
||||
i += 2
|
||||
}
|
||||
2 -> {
|
||||
model.setFg(rgbOf(nums.getOrNull(i + 2) ?: 0, nums.getOrNull(i + 3) ?: 0, nums.getOrNull(i + 4) ?: 0))
|
||||
i += 4
|
||||
}
|
||||
else -> i += 1
|
||||
}
|
||||
}
|
||||
39 -> model.setFg(model.fgDefault)
|
||||
40, 41, 42, 43, 44, 45, 46, 47 -> model.setBg(model.palette[nums[i] - 40])
|
||||
48 -> {
|
||||
val mode = nums.getOrNull(i + 1)
|
||||
when (mode) {
|
||||
5 -> {
|
||||
model.setBg(colorFrom256(nums.getOrNull(i + 2) ?: 0))
|
||||
i += 2
|
||||
}
|
||||
2 -> {
|
||||
model.setBg(rgbOf(nums.getOrNull(i + 2) ?: 0, nums.getOrNull(i + 3) ?: 0, nums.getOrNull(i + 4) ?: 0))
|
||||
i += 4
|
||||
}
|
||||
else -> i += 1
|
||||
}
|
||||
}
|
||||
49 -> model.setBg(model.bgDefault)
|
||||
90, 91, 92, 93, 94, 95, 96, 97 -> model.setFg(model.palette[nums[i] - 90 + 8])
|
||||
100, 101, 102, 103, 104, 105, 106, 107 -> model.setBg(model.palette[nums[i] - 100 + 8])
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
private fun rgbOf(r: Int, g: Int, b: Int): Int = (r shl 16) or (g shl 8) or b
|
||||
|
||||
private fun colorFrom256(idx: Int): Int {
|
||||
return if (idx in colorTable.indices) colorTable[idx] else model.fgDefault
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Build the xterm 256-colour table from a 16-colour palette. */
|
||||
fun build256Table(pal: IntArray): IntArray {
|
||||
val table = IntArray(256)
|
||||
// 0-15 = standard palette
|
||||
for (i in 0 until 16) table[i] = pal[i]
|
||||
// 16-231 = colour cube 6×6×6
|
||||
var k = 16
|
||||
for (r in 0..5) for (g in 0..5) for (b in 0..5) {
|
||||
val rr = if (r == 0) 0 else r * 40 + 55
|
||||
val gg = if (g == 0) 0 else g * 40 + 55
|
||||
val bb = if (b == 0) 0 else b * 40 + 55
|
||||
table[k++] = (rr shl 16) or (gg shl 8) or bb
|
||||
}
|
||||
// 232-255 = greyscale
|
||||
var v = 8
|
||||
for (i in 232..255) {
|
||||
table[i] = (v shl 16) or (v shl 8) or v
|
||||
v += 10
|
||||
}
|
||||
return table
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.kosbarokah.sshclient.terminal
|
||||
|
||||
import com.kosbarokah.sshclient.data.ThemePreset
|
||||
|
||||
/**
|
||||
* Holds the resolved colour scheme (16-colour palette + bg/fg/cursor) for the
|
||||
* terminal. The terminal [TerminalView] paints using these via the
|
||||
* [TerminalModel].
|
||||
*
|
||||
* TrueColor (24-bit) SGR escape sequences are handled natively by the
|
||||
* self-contained [TerminalParser], so colour apps (vim, htop, tmux, fzf, bat,
|
||||
* lsd) render at full fidelity.
|
||||
*/
|
||||
data class TerminalScheme(
|
||||
val palette: IntArray, // 16 entries (normal + bright)
|
||||
val background: Int,
|
||||
val foreground: Int,
|
||||
val cursor: Int
|
||||
) {
|
||||
companion object {
|
||||
fun fromTheme(name: String, dark: Boolean): TerminalScheme = TerminalScheme(
|
||||
palette = ThemePreset.palette(name),
|
||||
background = ThemePreset.background(name),
|
||||
foreground = ThemePreset.foreground(name),
|
||||
cursor = if (dark) 0x00e676 else 0x2e7d32
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package com.kosbarokah.sshclient.terminal
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* Custom View that renders a [TerminalModel] grid in a monospace font.
|
||||
*
|
||||
* Capabilities:
|
||||
* - full ANSI 16/256/TrueColor painting from cell fg/bg attributes
|
||||
* - pinch-to-zoom font size (14–60 px)
|
||||
* - vertical drag to scroll the scrollback history
|
||||
* - text selection (long-press start, drag extend) with copy to clipboard
|
||||
*
|
||||
* Public API used by TerminalActivity/TerminalController:
|
||||
* - computeCols(pixelWidth) / computeRows(pixelHeight)
|
||||
* - setFontSize(px) / setFontSizePx / currentFontSize()
|
||||
* - startSelection() / selectedText()
|
||||
*/
|
||||
class TerminalView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
private val model: TerminalModel
|
||||
) : View(context) {
|
||||
|
||||
private var fontSizePx = 28f
|
||||
private var cols: Int = model.width()
|
||||
private var rows: Int = model.height()
|
||||
|
||||
private var scrollOffset = 0 // >0 means showing scrollback (top rows hidden)
|
||||
private var selecting = false
|
||||
private var selStart: Pair<Int, Int>? = null // (col,row)
|
||||
private var selEnd: Pair<Int, Int>? = null
|
||||
|
||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = Typeface.MONOSPACE
|
||||
color = Color.WHITE
|
||||
}
|
||||
private val bgPaint = Paint()
|
||||
private val cursorPaint = Paint().apply { color = model.cursorColor }
|
||||
private val selPaint = Paint().apply {
|
||||
color = Color.argb(90, 0, 150, 255)
|
||||
}
|
||||
|
||||
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDown(e: MotionEvent): Boolean = true
|
||||
override fun onLongPress(e: MotionEvent) {
|
||||
startSelectionAt(e.x, e.y)
|
||||
}
|
||||
})
|
||||
|
||||
private val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
setFontSize(fontSizePx * detector.scaleFactor)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
init {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
// ---- helpers for sizing ----
|
||||
fun computeCols(pixelWidth: Int): Int {
|
||||
val cw = cellWidth()
|
||||
return if (cw > 0) (pixelWidth / cw).toInt().coerceAtLeast(2) else 80
|
||||
}
|
||||
|
||||
fun computeRows(pixelHeight: Int): Int {
|
||||
val ch = cellHeight()
|
||||
return if (ch > 0) (pixelHeight / ch).toInt().coerceAtLeast(2) else 24
|
||||
}
|
||||
|
||||
private fun cellWidth(): Float = textPaint.measureText("M")
|
||||
private fun cellHeight(): Float = textPaint.fontSpacing
|
||||
|
||||
fun setFontSize(px: Float) { setFontSizePx(px) }
|
||||
fun setFontSizePx(px: Float) {
|
||||
fontSizePx = px.coerceIn(14f, 60f)
|
||||
textPaint.textSize = fontSizePx
|
||||
// re-fit grid
|
||||
val c = computeCols(width.takeIf { it > 0 } ?: 900)
|
||||
val r = computeRows(height.takeIf { it > 0 } ?: 600)
|
||||
cols = c
|
||||
rows = r
|
||||
model.resize(c, r)
|
||||
invalidate()
|
||||
}
|
||||
fun currentFontSize(): Float = fontSizePx
|
||||
|
||||
// ---- selection ----
|
||||
fun startSelection() {
|
||||
selecting = true
|
||||
selStart = null
|
||||
selEnd = null
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun selectedText(): String? {
|
||||
val s = selStart ?: return null
|
||||
val e = selEnd ?: return null
|
||||
val (c1, r1) = s
|
||||
val (c2, r2) = e
|
||||
val sb = StringBuilder()
|
||||
val top = minOf(r1, r2)
|
||||
val bottom = maxOf(r1, r2)
|
||||
for (rr in top..bottom) {
|
||||
val lineStart = if (rr == top) minOf(c1, c2) else 0
|
||||
val lineEnd = if (rr == bottom) maxOf(c1, c2) else cols - 1
|
||||
for (cc in lineStart..lineEnd) {
|
||||
sb.append(model.cellAt(cc, rr).ch)
|
||||
}
|
||||
if (rr < bottom) sb.append('\n')
|
||||
}
|
||||
val text = sb.toString().trimEnd('\n')
|
||||
if (text.isNotEmpty()) {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("terminal", text))
|
||||
}
|
||||
selecting = false
|
||||
selStart = null
|
||||
selEnd = null
|
||||
invalidate()
|
||||
return text
|
||||
}
|
||||
|
||||
private fun startSelectionAt(x: Float, y: Float) {
|
||||
selecting = true
|
||||
selStart = cellAt(x, y)
|
||||
selEnd = selStart
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun cellAt(x: Float, y: Float): Pair<Int, Int> {
|
||||
val cw = cellWidth()
|
||||
val ch = cellHeight()
|
||||
val col = (x / cw).toInt().coerceIn(0, cols - 1)
|
||||
val row = (y / ch).toInt().coerceIn(0, rows - 1)
|
||||
return col to row
|
||||
}
|
||||
|
||||
// ---- touch ----
|
||||
private var lastTouchY = 0f
|
||||
private var dragging = false
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
scaleDetector.onTouchEvent(event)
|
||||
gestureDetector.onTouchEvent(event)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
lastTouchY = event.y
|
||||
dragging = false
|
||||
parent.requestDisallowInterceptTouchEvent(true)
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (selecting && selStart != null) {
|
||||
selEnd = cellAt(event.x, event.y)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
val dy = event.y - lastTouchY
|
||||
if (drivingScroll(dy)) return true
|
||||
lastTouchY = event.y
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (selecting) {
|
||||
selectedText() // copies automatically
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
private fun drivingScroll(dy: Float): Boolean {
|
||||
val lineH = cellHeight()
|
||||
if (kotlin.math.abs(dy) > lineH * 1.2f) {
|
||||
val steps = (dy / lineH).toInt()
|
||||
scrollOffset = (scrollOffset - steps).coerceIn(0, model.scrollbackLines().size)
|
||||
invalidate()
|
||||
dragging = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- drawing ----
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
// background
|
||||
bgPaint.color = model.bgDefault
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
|
||||
|
||||
val cw = cellWidth()
|
||||
val ch = cellHeight()
|
||||
val numRows = minOf(rows, ((height / ch).toInt()) + 1)
|
||||
|
||||
val sbLines = model.scrollbackLines()
|
||||
val showFromSb = if (scrollOffset > 0) {
|
||||
val startIdx = (sbLines.size - scrollOffset).coerceAtLeast(0)
|
||||
startIdx
|
||||
} else -1
|
||||
|
||||
for (r in 0 until numRows) {
|
||||
val lineSource: String? = if (scrollOffset > 0) {
|
||||
val idx = sbLines.size - scrollOffset + r
|
||||
if (idx in 0 until sbLines.size) sbLines[idx] else null
|
||||
} else null
|
||||
|
||||
val py = r * ch
|
||||
if (lineSource != null) {
|
||||
// scrollback line rendered with default colours
|
||||
for (c in 0 until cols) {
|
||||
val chVal = if (c < lineSource.length) lineSource[c] else ' '
|
||||
drawCell(canvas, c, py, TerminalCell(chVal, model.fgDefault, model.bgDefault, false, false, false), cw, ch)
|
||||
}
|
||||
} else {
|
||||
for (c in 0 until cols) {
|
||||
val cell = model.cellAt(c, r)
|
||||
drawCell(canvas, c, py, cell, cw, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cursor (only when not scrolled up)
|
||||
if (scrollOffset == 0 && !selecting) {
|
||||
val cy = model.getCursorRow() * ch
|
||||
val cx = model.getCursorCol() * cw
|
||||
cursorPaint.color = model.cursorColor
|
||||
canvas.drawRect(cx, cy, cx + cw, cy + ch, cursorPaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawCell(canvas: Canvas, col: Int, py: Float, cell: TerminalCell, cw: Float, ch: Float) {
|
||||
val px = col * cw
|
||||
var fg = cell.fg
|
||||
var bg = cell.bg
|
||||
if (cell.inverse) {
|
||||
val tmp = fg; fg = bg; bg = tmp
|
||||
}
|
||||
textPaint.isFakeBoldText = cell.bold
|
||||
|
||||
// selection highlight
|
||||
if (selecting && selStart != null && selEnd != null && isSelected(col, (py / ch).toInt())) {
|
||||
canvas.drawRect(px, py, px + cw, py + ch, selPaint)
|
||||
}
|
||||
|
||||
// background
|
||||
if (bg != model.bgDefault || cell.inverse) {
|
||||
bgPaint.color = bg
|
||||
canvas.drawRect(px, py, px + cw, py + ch, bgPaint)
|
||||
}
|
||||
|
||||
if (cell.ch != ' ') {
|
||||
textPaint.color = fg
|
||||
if (cell.underline) {
|
||||
val y = py + ch - ch * 0.15f
|
||||
textPaint.color = fg
|
||||
canvas.drawRect(px, y, px + cw, y + Math.max(1f, ch * 0.06f), textPaint)
|
||||
textPaint.color = fg
|
||||
}
|
||||
canvas.drawText(cell.ch.toString(), px, py + baselineOffset(ch), textPaint)
|
||||
}
|
||||
textPaint.isFakeBoldText = false
|
||||
}
|
||||
|
||||
/** Whether the given cell coordinate lies inside the current selection. */
|
||||
private fun isSelected(col: Int, row: Int): Boolean {
|
||||
val s = selStart ?: return false
|
||||
val e = selEnd ?: return false
|
||||
val (c1, r1) = s
|
||||
val (c2, r2) = e
|
||||
val rTop = minOf(r1, r2); val rBot = maxOf(r1, r2)
|
||||
val cLeft = minOf(c1, c2); val cRight = maxOf(c1, c2)
|
||||
if (row < rTop || row > rBot) return false
|
||||
val leftC = if (row == rTop) cLeft else 0
|
||||
val rightC = if (row == rBot) cRight else cols - 1
|
||||
return col in leftC..rightC
|
||||
}
|
||||
|
||||
private fun baselineOffset(ch: Float): Float {
|
||||
val fm = textPaint.fontMetrics
|
||||
return (fm.descent - fm.ascent) * 0.78f
|
||||
}
|
||||
|
||||
/** Resize the view's grid mapping when the view is sized. */
|
||||
fun reconfigure(w: Int, h: Int) {
|
||||
val c = computeCols(w)
|
||||
val rC = computeRows(h)
|
||||
cols = c
|
||||
rows = rC
|
||||
model.resize(c, rC)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.kosbarokah.sshclient.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.kosbarokah.sshclient.R
|
||||
import com.kosbarokah.sshclient.data.SecureStore
|
||||
import com.kosbarokah.sshclient.data.ServerProfile
|
||||
|
||||
class ConnectActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var store: SecureStore
|
||||
private lateinit var adapter: ProfileAdapter
|
||||
|
||||
private val editorLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_connect)
|
||||
|
||||
store = SecureStore(this)
|
||||
adapter = ProfileAdapter { profile ->
|
||||
val intent = Intent(this, TerminalActivity::class.java)
|
||||
intent.putExtra("profile_id", profile.id)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
val list = findViewById<RecyclerView>(R.id.profileList)
|
||||
list.layoutManager = LinearLayoutManager(this)
|
||||
list.adapter = adapter
|
||||
|
||||
findViewById<FloatingActionButton>(R.id.fabAdd).setOnClickListener {
|
||||
editorLauncher.launch(Intent(this, ProfileEditorActivity::class.java))
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
adapter.submit(store.getProfiles())
|
||||
findViewById<TextView>(R.id.emptyHint).visibility =
|
||||
if (store.getProfiles().isEmpty()) android.view.View.VISIBLE
|
||||
else android.view.View.GONE
|
||||
}
|
||||
|
||||
private class ProfileAdapter(
|
||||
private val onOpen: (ServerProfile) -> Unit
|
||||
) : RecyclerView.Adapter<ProfileAdapter.VH>() {
|
||||
|
||||
private var items: List<ServerProfile> = emptyList()
|
||||
|
||||
fun submit(list: List<ServerProfile>) {
|
||||
items = list
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_profile, parent, false)
|
||||
return VH(v as TextView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val p = items[position]
|
||||
holder.text.text = p.name
|
||||
holder.text.setCompoundDrawablesWithIntrinsicBounds(
|
||||
R.drawable.ic_server, 0, 0, 0
|
||||
)
|
||||
holder.text.setOnClickListener { onOpen(p) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
class VH(val text: TextView) : RecyclerView.ViewHolder(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.kosbarokah.sshclient.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import com.kosbarokah.sshclient.R
|
||||
import com.kosbarokah.sshclient.data.AuthMethod
|
||||
import com.kosbarokah.sshclient.data.SecureStore
|
||||
import com.kosbarokah.sshclient.data.ServerProfile
|
||||
import com.kosbarokah.sshclient.data.ThemePreset
|
||||
|
||||
class ProfileEditorActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var store: SecureStore
|
||||
private var editingId: String? = null
|
||||
|
||||
private lateinit var etName: EditText
|
||||
private lateinit var etHost: EditText
|
||||
private lateinit var etPort: EditText
|
||||
private lateinit var etUser: EditText
|
||||
private lateinit var authSpinner: Spinner
|
||||
private lateinit var etPassword: EditText
|
||||
private lateinit var etKey: EditText
|
||||
private lateinit var etPassphrase: EditText
|
||||
private lateinit var keyGroup: LinearLayout
|
||||
private lateinit var themeGroup: ChipGroup
|
||||
private lateinit var btnDelete: Button
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_profile_editor)
|
||||
|
||||
store = SecureStore(this)
|
||||
|
||||
etName = findViewById(R.id.etName)
|
||||
etHost = findViewById(R.id.etHost)
|
||||
etPort = findViewById(R.id.etPort)
|
||||
etUser = findViewById(R.id.etUser)
|
||||
authSpinner = findViewById(R.id.authSpinner)
|
||||
etPassword = findViewById(R.id.etPassword)
|
||||
etKey = findViewById(R.id.etKey)
|
||||
etPassphrase = findViewById(R.id.etPassphrase)
|
||||
keyGroup = findViewById(R.id.keyGroup)
|
||||
themeGroup = findViewById(R.id.themeChips)
|
||||
btnDelete = findViewById(R.id.btnDelete)
|
||||
|
||||
authSpinner.adapter = ArrayAdapter(
|
||||
this, android.R.layout.simple_spinner_item,
|
||||
listOf(getString(R.string.auth_password), getString(R.string.auth_key))
|
||||
)
|
||||
|
||||
// Toggle password / key UI
|
||||
authSpinner.setOnItemSelectedListener(object :
|
||||
android.widget.AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(
|
||||
parent: android.widget.AdapterView<*>?, view: View?, pos: Int, id: Long
|
||||
) {
|
||||
findViewById<View>(R.id.pwdGroup).visibility =
|
||||
if (pos == 0) View.VISIBLE else View.GONE
|
||||
keyGroup.visibility =
|
||||
if (pos == 1) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
|
||||
})
|
||||
|
||||
editingId = intent.getStringExtra("profile_id")
|
||||
if (editingId != null) populate(store.getProfile(editingId!!))
|
||||
else btnDelete.visibility = View.GONE
|
||||
|
||||
buildThemeChips()
|
||||
|
||||
findViewById<Button>(R.id.btnSave).setOnClickListener { save() }
|
||||
btnDelete.setOnClickListener { delete() }
|
||||
}
|
||||
|
||||
private fun buildThemeChips() {
|
||||
val themes = listOf(
|
||||
"Hijau" to ThemePreset.GREEN_DARK,
|
||||
"Tango" to ThemePreset.TANGO_DARK,
|
||||
"Solarized" to ThemePreset.SOLARIZED_DARK,
|
||||
"Solarized Terang" to ThemePreset.SOLARIZED_LIGHT,
|
||||
"Gruvbox" to ThemePreset.GRUVBOX,
|
||||
"Material" to ThemePreset.MATERIAL
|
||||
)
|
||||
for ((label, name) in themes) {
|
||||
val chip = Chip(this)
|
||||
chip.text = label
|
||||
chip.tag = name
|
||||
chip.isCheckable = true
|
||||
themeGroup.addView(chip)
|
||||
}
|
||||
if (themeGroup.childCount > 0) {
|
||||
(themeGroup.getChildAt(0) as Chip).isChecked = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun populate(p: ServerProfile?) {
|
||||
if (p == null) return
|
||||
etName.setText(p.name)
|
||||
etHost.setText(p.host)
|
||||
etPort.setText(p.port.toString())
|
||||
etUser.setText(p.username)
|
||||
if (p.authMethod == AuthMethod.PASSWORD) {
|
||||
authSpinner.setSelection(0)
|
||||
etPassword.setText(store.getPassword(p.id))
|
||||
} else {
|
||||
authSpinner.setSelection(1)
|
||||
etKey.setText(store.getPrivateKey(p.id))
|
||||
etPassphrase.setText(store.getPassphrase(p.id))
|
||||
}
|
||||
etName.hint = getString(R.string.field_name)
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
val name = etName.text.toString().trim()
|
||||
val host = etHost.text.toString().trim()
|
||||
val port = etPort.text.toString().toIntOrNull() ?: 22
|
||||
val user = etUser.text.toString().trim()
|
||||
if (name.isEmpty() || host.isEmpty() || user.isEmpty()) {
|
||||
Toast.makeText(this, "Nama, Host, dan Username wajib diisi", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val method = if (authSpinner.selectedItemPosition == 0) AuthMethod.PASSWORD else AuthMethod.PRIVATE_KEY
|
||||
val password = etPassword.text.toString()
|
||||
val privKey = etKey.text.toString()
|
||||
val passphrase = etPassphrase.text.toString()
|
||||
|
||||
val profile = ServerProfile(
|
||||
id = editingId ?: SecureStore.newId(),
|
||||
name = name,
|
||||
host = host,
|
||||
port = port,
|
||||
username = user,
|
||||
authMethod = method,
|
||||
themeName = selectedTheme(),
|
||||
fontSize = 28
|
||||
)
|
||||
|
||||
store.saveProfile(profile, password.ifEmpty { null }, privKey.ifEmpty { null }, passphrase.ifEmpty { null })
|
||||
setResult(Activity.RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun delete() {
|
||||
val id = editingId ?: return
|
||||
store.deleteProfile(id)
|
||||
setResult(Activity.RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun selectedTheme(): String {
|
||||
val chip = themeGroup.findViewById<Chip>(themeGroup.checkedChipId)
|
||||
return chip?.tag?.toString() ?: ThemePreset.GREEN_DARK
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.kosbarokah.sshclient.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.kosbarokah.sshclient.R
|
||||
import com.kosbarokah.sshclient.data.SecureStore
|
||||
import com.kosbarokah.sshclient.data.ServerProfile
|
||||
import com.kosbarokah.sshclient.keyboard.KeySequences
|
||||
import com.kosbarokah.sshclient.ssh.SshEngine
|
||||
import com.kosbarokah.sshclient.terminal.TerminalController
|
||||
import com.kosbarokah.sshclient.terminal.TerminalModel
|
||||
import com.kosbarokah.sshclient.terminal.TerminalScheme
|
||||
import com.kosbarokah.sshclient.terminal.TerminalView
|
||||
|
||||
/**
|
||||
* Renders the interactive PTY session and a custom control/keyboard bar.
|
||||
*
|
||||
* Hosts a [TerminalView] fed by our SshEngine via [TerminalController].
|
||||
* Handles reconnect, keep-alive, copy/paste/selection, dark/light UI and the
|
||||
* extra key row (Ctrl / Alt / Esc / Tab / arrows / PageUp/Down).
|
||||
*/
|
||||
class TerminalActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var store: SecureStore
|
||||
private lateinit var profile: ServerProfile
|
||||
|
||||
private lateinit var terminalContainer: android.widget.FrameLayout
|
||||
private lateinit var terminalView: TerminalView
|
||||
private lateinit var model: TerminalModel
|
||||
private lateinit var controller: TerminalController
|
||||
private lateinit var engine: SshEngine
|
||||
|
||||
private lateinit var statusBar: TextView
|
||||
private lateinit var keyRow: LinearLayout
|
||||
|
||||
private var modifier: String? = null // "CTRL" or "ALT" currently held
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_terminal)
|
||||
|
||||
store = SecureStore(this)
|
||||
val profileId = intent.getStringExtra("profile_id") ?: run {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val loaded = store.getProfile(profileId)
|
||||
if (loaded == null) {
|
||||
Toast.makeText(this, "Profil tidak ditemukan", Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
profile = loaded
|
||||
|
||||
terminalContainer = findViewById(R.id.terminalContainer)
|
||||
statusBar = findViewById(R.id.statusBar)
|
||||
keyRow = findViewById(R.id.keyRow)
|
||||
|
||||
// Build self-contained terminal from the saved colour theme + font size.
|
||||
val scheme = TerminalScheme.fromTheme(
|
||||
profile.themeName,
|
||||
dark = true
|
||||
)
|
||||
model = TerminalModel(
|
||||
palette = com.kosbarokah.sshclient.data.ThemePreset.palette(profile.themeName),
|
||||
bgDefault = scheme.background,
|
||||
fgDefault = scheme.foreground,
|
||||
cursorColor = scheme.cursor
|
||||
)
|
||||
terminalView = TerminalView(this, model).apply { setFontSize(profile.fontSize.toFloat()) }
|
||||
terminalContainer.addView(
|
||||
terminalView,
|
||||
android.widget.FrameLayout.LayoutParams(
|
||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
controller = TerminalController(terminalView, model)
|
||||
|
||||
val password = store.getPassword(profile.id)
|
||||
val key = store.getPrivateKey(profile.id)
|
||||
val passphrase = store.getPassphrase(profile.id)
|
||||
|
||||
engine = SshEngine(
|
||||
profile = profile,
|
||||
password = password,
|
||||
privateKey = key,
|
||||
passphrase = passphrase
|
||||
)
|
||||
|
||||
controller.attach(
|
||||
engine = engine,
|
||||
statusBar = { msg -> runOnUiThread { statusBar.text = msg } }
|
||||
)
|
||||
|
||||
// Keep PTY rows/cols in sync when the terminal lays out.
|
||||
terminalView.addOnLayoutChangeListener { _, left, top, right, bottom, _, _, _, _ ->
|
||||
val cols = terminalView.computeCols(right - left)
|
||||
val rows = terminalView.computeRows(bottom - top)
|
||||
if (cols > 0 && rows > 0) controller.onModelResize(cols, rows)
|
||||
}
|
||||
|
||||
setupCustomKeyboard()
|
||||
setDarkLightUi(true)
|
||||
engine.start()
|
||||
}
|
||||
|
||||
private fun setupCustomKeyboard() {
|
||||
// Row of helper keys: send sequences directly to the SSH session.
|
||||
fun add(text: String, seq: ByteArray) {
|
||||
val tv = TextView(this).apply {
|
||||
this.text = text
|
||||
setPadding(18, 14, 18, 14)
|
||||
setTextColor(0xffeceff1.toInt())
|
||||
background = getDrawable(R.drawable.key_bg)
|
||||
setOnClickListener {
|
||||
modifier?.let {
|
||||
// Prepend Ctrl/Alt to the following sequence.
|
||||
if (it == "CTRL") controller.writeToSession(keySeqWithCtrl(seq))
|
||||
else if (it == "ALT") controller.writeToSession(byteArrayOf(0x1b) + seq)
|
||||
return@setOnClickListener
|
||||
}
|
||||
controller.writeToSession(seq)
|
||||
}
|
||||
}
|
||||
keyRow.addView(tv, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
}
|
||||
|
||||
// toggle buttons
|
||||
addToggle("Ctrl", "CTRL"); addToggle("Alt", "ALT")
|
||||
add("Esc", KeySequences.esc)
|
||||
add("Tab", KeySequences.tab)
|
||||
add("↑", KeySequences.up())
|
||||
add("↓", KeySequences.down())
|
||||
add("←", KeySequences.left())
|
||||
add("→", KeySequences.right())
|
||||
add("PgUp", KeySequences.pageUp)
|
||||
add("PgDn", KeySequences.pageDown)
|
||||
add("Del", KeySequences.delete)
|
||||
}
|
||||
|
||||
private fun addToggle(label: String, tag: String) {
|
||||
val tv = TextView(this).apply {
|
||||
this.text = label
|
||||
this.tag = tag
|
||||
setPadding(18, 14, 18, 14)
|
||||
setTextColor(0xffeceff1.toInt())
|
||||
background = getDrawable(R.drawable.key_bg)
|
||||
setOnClickListener {
|
||||
modifier = if (modifier == tag) null else tag
|
||||
alpha = if (modifier == tag) 1f else 0.6f
|
||||
}
|
||||
}
|
||||
keyRow.addView(tv, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
tv.alpha = 0.6f
|
||||
}
|
||||
|
||||
/** Ctrl+letter mapped from the byte sequence — for our buttons we send ctrl-<first char>. */
|
||||
private fun keySeqWithCtrl(base: ByteArray): ByteArray {
|
||||
// For printable base (e.g. a key), map to control code. Most helper
|
||||
// sequences are already ANSI escapes, so Ctrl+SEQ = ESC prefix + seq.
|
||||
return byteArrayOf(0x1b) + base
|
||||
}
|
||||
|
||||
private fun setDarkLightUi(dark: Boolean) {
|
||||
// Status bar + keyboard contrast.
|
||||
statusBar.setTextColor(0xffffffff.toInt())
|
||||
}
|
||||
|
||||
// ---- copy / paste / selection toolbar actions ----
|
||||
fun onCopyClicked(v: View) {
|
||||
val sel = controller.copySelection()
|
||||
if (sel.isNullOrEmpty()) {
|
||||
Toast.makeText(this, "Tidak ada teks terseleksi", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("terminal", sel))
|
||||
Toast.makeText(this, "Disalin", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
fun onPasteClicked(v: View) {
|
||||
val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val text = cm.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString()
|
||||
if (!text.isNullOrEmpty()) controller.writeToSessionUtf8(text)
|
||||
}
|
||||
|
||||
fun onSelectClicked(v: View) {
|
||||
controller.startSelection()
|
||||
}
|
||||
|
||||
fun onCloseClicked(v: View) {
|
||||
engine.disconnect()
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
engine.disconnect()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
Log.d("TerminalActivity", "stopped; connection kept alive by engine thread")
|
||||
}
|
||||
}
|
||||
16
app/src/main/res/drawable/ic_launcher.xml
Normal file
16
app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Terminal / SSH icon: dark rounded backdrop with green ">" prompt -->
|
||||
<path
|
||||
android:fillColor="#1B5E20"
|
||||
android:pathData="M0,24a24,24 0 0,1 24,-24h60a24,24 0 0,1 24,24v60a24,24 0 0,1 -24,24h-60a24,24 0 0,1 -24,-24z" />
|
||||
<path
|
||||
android:fillColor="#00E676"
|
||||
android:pathData="M30,36 L30,42 L42,54 L30,66 L30,72 L54,54 z" />
|
||||
<path
|
||||
android:fillColor="#00E676"
|
||||
android:pathData="M54,72 L78,72 L78,64 L54,64 z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_server.xml
Normal file
10
app/src/main/res/drawable/ic_server.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#2E7D32">
|
||||
<path
|
||||
android:fillColor="#2E7D32"
|
||||
android:pathData="M4,2h16a2,2 0 0,1 2,2v3a2,2 0 0,1 -2,2H4A2,2 0 0,1 2,7V4A2,2 0 0,1 4,2zM4,11h16a2,2 0 0,1 2,2v4a2,2 0 0,1 -2,2H4a2,2 0 0,1 -2,-2v-4a2,2 0 0,1 2,-2zM6,5v1H8V5zM6,14v1H8v-1z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/item_bg.xml
Normal file
10
app/src/main/res/drawable/item_bg.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/surface" />
|
||||
<stroke android:width="1dp" android:color="#DDDDDD" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
6
app/src/main/res/drawable/key_bg.xml
Normal file
6
app/src/main/res/drawable/key_bg.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/terminal_keyboard_btn" />
|
||||
<corners android:radius="6dp" />
|
||||
<stroke android:width="1dp" android:color="#1C262D" />
|
||||
</shape>
|
||||
44
app/src/main/res/layout/activity_connect.xml
Normal file
44
app/src/main/res/layout/activity_connect.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_profiles"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/primary_dark" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyHint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerInParent="true"
|
||||
android:gravity="center"
|
||||
android:padding="24dp"
|
||||
android:text="@string/profiles_empty"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/profileList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_below="@id/title"
|
||||
android:layout_marginTop="16dp"
|
||||
android:paddingBottom="96dp"
|
||||
android:clipToPadding="false" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fabAdd"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_margin="20dp"
|
||||
app:srcCompat="@android:drawable/ic_input_add" />
|
||||
</RelativeLayout>
|
||||
169
app/src/main/res/layout/activity_profile_editor.xml
Normal file
169
app/src/main/res/layout/activity_profile_editor.xml
Normal file
@@ -0,0 +1,169 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:background="@color/bg">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Profil Server SSH"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/primary_dark" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lblName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/field_name" />
|
||||
<EditText
|
||||
android:id="@+id/etName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="mis. Server Utama" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lblHost"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/field_host" />
|
||||
<EditText
|
||||
android:id="@+id/etHost"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="192.168.1.10 atau host.com"
|
||||
android:inputType="textUri" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lblPort"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/field_port" />
|
||||
<EditText
|
||||
android:id="@+id/etPort"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="22"
|
||||
android:inputType="number" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lblUser"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/field_user" />
|
||||
<EditText
|
||||
android:id="@+id/etUser"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="root" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lblAuth"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/field_auth" />
|
||||
<Spinner
|
||||
android:id="@+id/authSpinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<!-- Password block -->
|
||||
<LinearLayout
|
||||
android:id="@+id/pwdGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/field_password" />
|
||||
<EditText
|
||||
android:id="@+id/etPassword"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Private key block -->
|
||||
<LinearLayout
|
||||
android:id="@+id/keyGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/field_key" />
|
||||
<EditText
|
||||
android:id="@+id/etKey"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textMultiLine"
|
||||
android:minLines="4"
|
||||
android:gravity="top"
|
||||
android:hint="-----BEGIN OPENSSH PRIVATE KEY-----" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/field_key_passphrase" />
|
||||
<EditText
|
||||
android:id="@+id/etPassphrase"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/theme_picker" />
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/themeChips"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:singleSelection="true"
|
||||
app:selectionRequired="true" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSave"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/btn_save" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnDelete"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/btn_delete"
|
||||
android:textColor="#C62828" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
84
app/src/main/res/layout/activity_terminal.xml
Normal file
84
app/src/main/res/layout/activity_terminal.xml
Normal file
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/terminalRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="#000000">
|
||||
|
||||
<!-- Status / connection bar -->
|
||||
<TextView
|
||||
android:id="@+id/statusBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:background="@color/terminal_statusbar_bg"
|
||||
android:textColor="@color/terminal_statusbar_text"
|
||||
android:text="Menghubungkan…"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- Terminal view fills remaining vertical space -->
|
||||
<FrameLayout
|
||||
android:id="@+id/terminalContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<!-- Toolbar overlay (top-right) -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="top|end"
|
||||
android:padding="6dp"
|
||||
android:background="@color/terminal_keyboard_bg"
|
||||
android:elevation="4dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Pilih"
|
||||
android:padding="10dp"
|
||||
android:textColor="#ECEFF1"
|
||||
android:onClick="onSelectClicked" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Salin"
|
||||
android:padding="10dp"
|
||||
android:textColor="#ECEFF1"
|
||||
android:onClick="onCopyClicked" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Tempel"
|
||||
android:padding="10dp"
|
||||
android:textColor="#ECEFF1"
|
||||
android:onClick="onPasteClicked" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="✕"
|
||||
android:padding="10dp"
|
||||
android:textColor="#ECEFF1"
|
||||
android:onClick="onCloseClicked" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Custom extra key row (Ctrl/Alt/Esc/Tab/arrows...) -->
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/terminal_keyboard_bg">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/keyRow"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="4dp" />
|
||||
</HorizontalScrollView>
|
||||
</LinearLayout>
|
||||
15
app/src/main/res/layout/item_profile.xml
Normal file
15
app/src/main/res/layout/item_profile.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/profileItem"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="64dp"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingVertical="16dp"
|
||||
android:textSize="17sp"
|
||||
android:textColor="@color/primary_dark"
|
||||
android:background="@drawable/item_bg"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginVertical="6dp"
|
||||
android:drawablePadding="16dp" />
|
||||
17
app/src/main/res/values/colors.xml
Normal file
17
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="terminal_bg">#000000</color>
|
||||
<color name="terminal_text">#FFFFFF</color>
|
||||
<color name="terminal_accent">#00E676</color>
|
||||
<color name="terminal_keyboard_bg">#263238</color>
|
||||
<color name="terminal_keyboard_btn">#37474F</color>
|
||||
<color name="terminal_keyboard_text">#ECEFF1</color>
|
||||
<color name="terminal_statusbar_bg">#1B5E20</color>
|
||||
<color name="terminal_statusbar_text">#FFFFFF</color>
|
||||
|
||||
<color name="primary">#2E7D32</color>
|
||||
<color name="primary_dark">#1B5E20</color>
|
||||
<color name="accent">#FFB300</color>
|
||||
<color name="bg">#FAFAFA</color>
|
||||
<color name="surface">#FFFFFF</color>
|
||||
</resources>
|
||||
53
app/src/main/res/values/strings.xml
Normal file
53
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SSH Mobile Client</string>
|
||||
<string name="app_description">SSH client for Android by Kos Barokah</string>
|
||||
|
||||
<!-- Activity titles -->
|
||||
<string name="title_connect">Sambungkan</string>
|
||||
<string name="title_terminal">Terminal</string>
|
||||
<string name="title_profiles">Profil Server</string>
|
||||
<string name="title_editor">Profil Server</string>
|
||||
|
||||
<!-- Profiles screen -->
|
||||
<string name="profiles_empty">Belum ada profil. Ketuk + untuk menambahkan server SSH.</string>
|
||||
<string name="profiles_add">Tambah Profil</string>
|
||||
<string name="btn_connect">Hubungkan</string>
|
||||
<string name="btn_edit">Edit</string>
|
||||
<string name="btn_delete">Hapus</string>
|
||||
|
||||
<!-- Connection -->
|
||||
<string name="connecting">Menghubungkan ke %1$s …</string>
|
||||
<string name="connected">Terhubung 🎉</string>
|
||||
<string name="conn_failed">Gagal menghubungkan:\n%1$s</string>
|
||||
<string name="conn_closed">Koneksi tertutup</string>
|
||||
<string name="auth_failed">Autentikasi gagal. Cek kredensial.</string>
|
||||
<string name="reconnecting">Koneksi putus. Menghubungkan ulang…</string>
|
||||
|
||||
<!-- Edit profile fields -->
|
||||
<string name="field_name">Nama Profil</string>
|
||||
<string name="field_host">Host / IP</string>
|
||||
<string name="field_port">Port</string>
|
||||
<string name="field_user">Username</string>
|
||||
<string name="field_auth">Metode Autentikasi</string>
|
||||
<string name="auth_password">Password</string>
|
||||
<string name="auth_key">Private Key</string>
|
||||
<string name="field_password">Password</string>
|
||||
<string name="field_key">Private Key (PEM)</string>
|
||||
<string name="field_key_passphrase">Passphrase (optional)</string>
|
||||
<string name="btn_save">Simpan</string>
|
||||
|
||||
<!-- Theme -->
|
||||
<string name="theme_picker">Tema Terminal</string>
|
||||
<string name="theme_dark">Gelap</string>
|
||||
<string name="theme_light">Terang</string>
|
||||
|
||||
<!-- Keyboard -->
|
||||
<string name="keyboard_hide">Sembunyikan papan ketik</string>
|
||||
<string name="keyboard_esc">Esc</string>
|
||||
<string name="keyboard_tab">Tab</string>
|
||||
<string name="keyboard_up">↑</string>
|
||||
<string name="keyboard_down">↓</string>
|
||||
<string name="keyboard_left">←</string>
|
||||
<string name="keyboard_right">→</string>
|
||||
</resources>
|
||||
16
app/src/main/res/values/themes.xml
Normal file
16
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.KosSSHClient" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorPrimaryDark">@color/primary_dark</item>
|
||||
<item name="colorAccent">@color/accent</item>
|
||||
<item name="android:statusBarColor">@color/primary_dark</item>
|
||||
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||
<item name="android:windowBackground">@color/bg</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.KosSSHClient.Dialog" parent="Theme.Material3.Light.Dialog">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorAccent">@color/accent</item>
|
||||
</style>
|
||||
</resources>
|
||||
6
app/src/main/res/xml/network_security_config.xml
Normal file
6
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Allowed hosts are added by the user via saved profiles; we allow cleartext
|
||||
since SSH hosts may be on LAN/internal IPs during development. -->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
Reference in New Issue
Block a user