diff --git a/.gitignore b/.gitignore index 2134d54..6833c0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,9 @@ .gradle/ -build/ -/build/ -app/build/ -local.properties .idea/ *.iml -.DS_Store -/captures -.externalNativeBuild -.cxx +local.properties +/build/ +/captures/ +.externalNativeBuild/ +.cxx/ +app/build/ diff --git a/README.md b/README.md index 3c90931..65f5b22 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,34 @@ # KosSSH Mobile -Android SSH mobile client starter project with interactive PTY, ANSI color rendering, custom terminal font size, keyboard helper keys, and password authentication. +Android SSH mobile client MVP, Kotlin + Android Views + JSch. -## Build +## SDK / Build -Open this folder in Android Studio (Ladybug or newer) and let Gradle sync. Then run on an Android 8.0+ device/emulator. +- `compileSdk = 37` +- `targetSdk = 37` +- `minSdk = 26` +- Android Gradle Plugin: `9.4.0` +- Gradle: `9.6.0` +- Java: 17 +- Android Studio: use a version that supports API 37 (Android Studio Panda 3 or newer). -Or from a machine with Gradle/Android SDK configured: +Android 17 / API 37 requires apps targeting API 37+ that connect to LAN devices to declare and request `ACCESS_LOCAL_NETWORK` at runtime. KosSSH requests it when the user starts an SSH connection. -```bash -./gradlew assembleDebug -``` +## Features -APK output: -`app/build/outputs/apk/debug/app-debug.apk` +- SSH-2 interactive PTY (`xterm-256color`) +- Password authentication +- ANSI SGR basic colors + bold +- Custom terminal font size +- GitHub Dark theme +- CTRL dialog, ESC, TAB and arrow keys +- Android software keyboard integration +- Scrollback buffer -## Current features +## Security note -- SSH shell over JSch (SSH-2) -- PTY `xterm-256color` -- Interactive keyboard via InputConnection -- ANSI SGR basic 16-color + bright color + bold -- Adjustable monospace font size -- Dark terminal theme -- Ctrl+A-Z helper -- Tab/Esc/arrow helper buttons -- Host/port/user/password fields +The current MVP still disables strict host-key verification in `SshClient.kt` to simplify initial testing. Do not ship this configuration to production. The next hardening step should add a persistent known-hosts store and explicit host-key verification, followed by encrypted server profiles/private-key storage. -## Important production hardening +## Android 17 note -This is a functional MVP, not yet a production-grade SSH client. Before shipping, replace `StrictHostKeyChecking=no` in `SshClient.kt` with persistent known-hosts / host-key fingerprint verification, add encrypted profile storage with Android Keystore, private-key authentication, SFTP, terminal resize (`windowChange`), robust VT100/VT220/VT420 parsing, selection/copy, scrollback UI, and connection lifecycle handling. - -## Package - -`com.bozz.kosssh` +Android 17 (API 37) adds a runtime local-network permission. For SSH servers on a LAN, the app asks for `ACCESS_LOCAL_NETWORK` immediately before the connection is opened. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b9827a..6335453 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,16 +1,33 @@ 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" +android { + namespace = "com.bozz.kosssh" + compileSdk = 37 + + defaultConfig { + applicationId = "com.bozz.kosssh" + minSdk = 26 + targetSdk = 37 + versionCode = 2 + versionName = "1.1.0" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + buildToolsVersion = "37.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("androidx.core:core-ktx:1.17.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("com.google.android.material:material:1.13.0") implementation("com.github.mwiede:jsch:0.2.23") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 428b9b5..458e216 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + diff --git a/app/src/main/java/com/bozz/kosssh/terminal/TerminalView.kt b/app/src/main/java/com/bozz/kosssh/terminal/TerminalView.kt index 7ee72f1..51fcf75 100644 --- a/app/src/main/java/com/bozz/kosssh/terminal/TerminalView.kt +++ b/app/src/main/java/com/bozz/kosssh/terminal/TerminalView.kt @@ -3,12 +3,17 @@ package com.bozz.kosssh.terminal import android.content.Context import android.graphics.* import android.text.InputType +import android.util.AttributeSet import android.view.* import android.view.inputmethod.* import java.util.ArrayDeque import kotlin.math.max -class TerminalView(context: Context) : View(context) { +class TerminalView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE } private val lines = ArrayDeque>() private var current = mutableListOf() @@ -154,5 +159,6 @@ class TerminalView(context: Context) : View(context) { } fun sendCtrl(letter: Char) { send(byteArrayOf((letter.uppercaseChar().code - 64).toByte())) } - private fun send(s: String) { sender?.invoke(s.toByteArray(Charsets.UTF_8)) } + private fun send(s: String) { send(s.toByteArray(Charsets.UTF_8)) } + private fun send(data: ByteArray) { sender?.invoke(data) } } diff --git a/app/src/main/java/com/bozz/kosssh/ui/MainActivity.kt b/app/src/main/java/com/bozz/kosssh/ui/MainActivity.kt index 568efcf..fe64aaf 100644 --- a/app/src/main/java/com/bozz/kosssh/ui/MainActivity.kt +++ b/app/src/main/java/com/bozz/kosssh/ui/MainActivity.kt @@ -1,7 +1,9 @@ package com.bozz.kosssh.ui import android.app.* +import android.content.pm.PackageManager import android.graphics.Color +import android.os.Build import android.os.Bundle import android.view.View import android.widget.* @@ -10,6 +12,9 @@ import com.bozz.kosssh.ssh.SshClient import com.bozz.kosssh.terminal.TerminalView class MainActivity : Activity() { + companion object { + private const val REQUEST_LOCAL_NETWORK = 3701 + } private lateinit var terminal: TerminalView private val ssh = SshClient() private lateinit var status: TextView @@ -38,9 +43,43 @@ class MainActivity : Activity() { val pass = findViewById(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() + + if (Build.VERSION.SDK_INT >= 37 && + checkSelfPermission(android.Manifest.permission.ACCESS_LOCAL_NETWORK) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions( + arrayOf(android.Manifest.permission.ACCESS_LOCAL_NETWORK), + REQUEST_LOCAL_NETWORK + ) + pendingConnection = ConnectionRequest(host, port, user, pass) + status.text = "Menunggu izin jaringan lokal…" + return + } + + startSsh(host, port, user, pass) + } + + private data class ConnectionRequest(val host: String, val port: Int, val user: String, val pass: String) + private var pendingConnection: ConnectionRequest? = null + + private fun startSsh(host: String, port: Int, user: String, pass: String) { ssh.connect(host, port, user, pass, { data -> runOnUiThread { terminal.feed(data) } }, { s -> runOnUiThread { status.text = s } }) } + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_LOCAL_NETWORK) { + val request = pendingConnection + pendingConnection = null + if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED && request != null) { + startSsh(request.host, request.port, request.user, request.pass) + } else { + status.text = "Izin jaringan lokal ditolak" + Toast.makeText(this, "KosSSH membutuhkan izin jaringan lokal untuk koneksi SSH LAN.", Toast.LENGTH_LONG).show() + } + } + } + private fun showCtrlDialog() { val letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" AlertDialog.Builder(this).setTitle("CTRL + …").setItems(letters.map { "Ctrl + $it" }.toTypedArray()) { _, which -> terminal.sendCtrl(letters[which]) }.show() diff --git a/build.gradle.kts b/build.gradle.kts index c3cf1b1..1216438 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ - plugins { - id("com.android.application") version "8.6.1" apply false - id("org.jetbrains.kotlin.android") version "2.4.10" apply false +plugins { + id("com.android.application") version "9.3.2" apply false } diff --git a/gradle.properties b/gradle.properties index 3a4c490..e696167 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ - org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..2a29db8 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b96cc4b9c59b5bff244af296e50d5ea3/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fdfa2f9fadcf75fb9616fde209d60b55/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b96cc4b9c59b5bff244af296e50d5ea3/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fdfa2f9fadcf75fb9616fde209d60b55/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/97b61e8788c7490a5f53d8951b11ea7a/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/feb8af2956f2e263086e0a8f9a1df390/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b96cc4b9c59b5bff244af296e50d5ea3/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fdfa2f9fadcf75fb9616fde209d60b55/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/eff42deed1d8947129a4e66cd07a89f4/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/8fe418192094ee8db0da620bcb6b1629/redirect +toolchainVendor=JETBRAINS +toolchainVersion=25 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 19a6bde..7e7d24f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index f3cacbb..8a85b7b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,21 @@ -pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } -dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } } +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + rootProject.name = "KosSSHMobile" include(":app")