feat: initialize Android SSH manager app with terminal and crypto

This commit is contained in:
Tronax 2026-08-03 18:04:29 +02:00
commit 66417cc194
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
55 changed files with 3711 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
*.iml
.gradle/
.idea/
.kotlin/
local.properties
.DS_Store
build/
captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
*.ap_
*.dex
# Keep the gradle wrapper jar (it IS the build tool bootstrap).
!gradle/wrapper/gradle-wrapper.jar

View file

@ -0,0 +1,69 @@
# SSH Connection Manager (Android, Kotlin + Jetpack Compose) — Bauplan
## Wichtige Vorbemerkung: 1Password auf Android
**Echte native 1Password-SSH-Integration ist auf Android technisch nicht möglich** — der 1Password SSH Agent und die SDKs sind Desktop-only (macOS/Windows/Linux), es gibt keine Kotlin/Java-API und keinen Android-Agent, nur einen offenen [Feature-Request](https://www.1password.community/developers-69/feature-request-android-and-ssh-19991). Du hast dich für **„Nur Datei-Import"** entschieden: Private Keys werden per Storage-Access-Framework importiert, AES-verschlüsselt (Android Keystore) gespeichert. Der praktische 1P-Workflow ist dann: Key auf dem Desktop aus 1Password exportieren → aufs Handy übertragen → in der App importieren. Kein 1Password-Bezug im UI.
## Stack & Abhängigkeiten (je current stable, 2026)
- **Kotlin 2.0+ (K2)**, AGP 8.7+, JDK 17, Compile/Target SDK 35, **Min SDK 26**
- **Jetpack Compose** (BOM), Material 3, dunkles Theme, Single-Activity + Navigation-Compose
- **Hilt** (DI, via KSP), **Room** 2.6+ (via KSP), Coroutines + Flow
- **sshj 0.39.x** — SSH-Verbindung, Auth (Passwort + Public Key), Shell/PTY
- **Termux `terminal-emulator`** via JitPack (`v0.118.3`) — VT100/xterm-Emulation (state machine, ANSI-Parsing, Buffer)
- **Bouncy Castle** (`bcprov-jdk18on`) — registriert als Provider, damit sshj OpenSSH-/Ed25519-Keys liest
- **Android Keystore** — AES-GCM-Schlüssel zur Feldverschlüsselung (Passwörter, private Keys)
## Architektur (Clean Layering, Paket-Struktur)
```
core/crypto KeystoreCrypto (AES-GCM Ver-/Entschlüsselung sensitiver Felder)
data Room: AppDatabase, Entities (Host, SshKey, Group), DAOs, Repositories, DI
domain/model Host, SshKey, Group, AuthMethod (sealed)
ssh SshConnectionManager (sshj SSHClient, Auth, Shell), SshSession, KeyLoader, DI
terminal SshTerminalBridge, TerminalShell, KeyEncoder, ui/TerminalScreen (Canvas), ViewModel
ui theme, nav (AppNavGraph, Routes), hostlist, hosteditor, keylist, keyimport, components
```
## Datenmodell (Room)
- **Host**: id, name, hostName, port, userName, groupId?, authType (PASSWORD/KEY), passwordCipher?, keyId?, lastConnected?
- **SshKey**: id, name, keyType, fingerprint, privateKeyCipher, hasPassphrase, createdAt
- **Group** (optional): id, name, sortIndex — zum Ordnen von Hosts
## Sicherheits-Workflow
1. Key-Import via SAF (`ACTION_OPEN_DOCUMENT`, MIME `*/*`), Datei als String lesen.
2. Mit sshj parsen → Typ + Fingerprint bestimmen; ggf. Passphrase abfragen.
3. Private Key mit Keystore-AES-GCM verschlüsseln, Ciphertext in Room speichern, Klartext sofort verwerfen.
4. Beim Verbinden: entschlüsseln → in den Arbeitsspeicher → sshj laden → verbinden → verwerfen.
5. Host-Passwörter analog verschlüsselt.
## Terminal-Integration (Kernbaustein)
Bidirektionale Brücke zwischen **sshj-Shell-Stream** und **Termux-`TerminalEmulator`**:
- sshj `Shell` → Output-Bytes in Reader-Thread → `TerminalEmulator.process(bytes)` → aktualisiert `TerminalBuffer`.
- Tastatur-Eingabe → `KeyEncoder` (Pfeiltasten etc. → ESC-Sequenzen) → `SshTerminalBridge.write()` → sshj Shell-OutputStream.
- `TerminalEmulator` direkt verwendet (nicht `TerminalSession`, das lokale Prozesse startet).
- **Renderer**: eigene dünne `Canvas`-Composable, die `TerminalBuffer` rendert — orientiert an Termux' `TerminalRenderer`. Scope: monospaces Raster, 16/256-Farben-Palette, bold/italic/underline/inverse, Cursor, Scrollback, Alternate Screen. Deckt reale Server-Sessions (`vim`, `htop`, Shell) ab; exotisches (Sixel/Maus) bewusst out-of-scope.
- IME-Anpassung (sichtbare Soft-Tastatur, Ctrl/Alt/Esc/Tastatur-Erweiterungsleiste) als Bestandteil.
## UI-Screens (Compose)
1. **HostList** — gruppierte Liste, Suche, Connect-Action, Swipe-to-delete; FAB „Host hinzufügen".
2. **HostEditor** — Felder für Name/Host/Port/User, Auth-Auswahl (Passwort vs. Key-Auswahl), Group-Zuordnung, „Verbindung testen".
3. **KeyList** — Keys mit Fingerprint/Typ, Import-Button, Löschen.
4. **KeyImport** — Datei wählen (SAF), Name, Passphrase, Vorschau-Validierung.
5. **Terminal** — Fullscreen Canvas-Terminal + Tastaturleiste; Connection-Status, Disconnect.
## Build-Reihenfolge (Phasen)
1. **Scaffold**: Gradle (Version Catalog `libs.versions.toml`), Manifest, Application (Hilt), MainActivity, Theme, Navigation-Gerüst.
2. **Daten & Crypto**: Room-Entities/DAOs/DB, Repos, `KeystoreCrypto`, DI-Module.
3. **UI CRUD**: HostList, HostEditor, KeyList, KeyImport (SAF + sshj-Parse + Verschlüsselung). Ab hier lauffähiger Manager.
4. **SSH-Layer**: `SshConnectionManager` (sshj Connect + Auth Passwort/Key), „Verbindung testen".
5. **Terminal**: Brücke + `TerminalEmulator`-Wiring + Canvas-Renderer + Tastatur. End-to-end SSH-Session im Terminal.
6. **Robustheit**: Connection-Lifecycle im process-globalen Singleton (überlebt Konfigwechsel), Fehler-/Timeout-Handling, Später: Foreground-Service für echte Hintergrund-Persistenz (als Erweiterung markiert).
## Ehrliche Hinweise / Risiken
- **Terminal-Renderer**: komplette xterm-Fidelity ist kein Ziel; angestrebt ist ein robuster Renderer für typische Shell/`vim`/`htop`-Nutzung. Polarisierung (Unicode-Width, Truetype-Monospace-Messing) inkrementell.
- **Hintergrund-Persistenz** echter Sessions braucht einen Foreground-Service (mit Notification) — im MVP wird die Session nur vor Config-Wechsel geschützt; der FG-Service ist klar als Folge-Task markiert.
- **Kein echtes 1Password**: wird im UI ehrlich nicht suggeriert.
## Was du am Ende hast
Ein lauffähiges Android-App-Projekt: Hosts/Keys verwalten (verschlüsselt gespeichert), über Passwort oder importierten Key verbinden, und ein **interaktives Compose-Terminal** für SSH-Sessions. Bauen mit `./gradlew assembleDebug`.
## Umsetzungshinweis
Ich lege das komplette Projekt als neues Gradle-Projekt an (es ist aktuell ein leeres Verzeichnis) und implementiere alle Phasen. Bei sehr umfangreichen einzelnen Klassen (Terminal-Renderer, SshConnectionManager) halte ich sie fokunktional, aber schlank und klar kommentiert.

119
README.md Normal file
View file

@ -0,0 +1,119 @@
# Mobile SSH Manager
Android-App (Kotlin + Jetpack Compose) zum Verwalten von SSH-Verbindungen und zum
interaktiven Arbeiten auf Servern über ein eingebautes Terminal.
## Stand / 1Password-Hinweis (bitte lesen)
Der ursprüngliche Wunsch war **1Password SSH Key Support**. Das ist auf Android
aktuell **technisch nicht möglich**:
- Der 1Password SSH Agent ist **Desktop-only** (macOS, Windows, Linux).
- Es gibt **keine Kotlin/Java-SDK** und keinen Android-Agent, nur Go/JS/Python-SDKs
(Desktop/Server) und einen offenen [Feature-Request für Android][1p-fr].
- Drittanbieter-Apps können 1Password-Schlüssel auf Android nicht einbinden.
[1p-fr]: https://www.1password.community/developers-69/feature-request-android-and-ssh-19991
**Stattdessen** unterstützt diese App den realistischen Workflow:
Private Keys werden als Datei importiert (Storage Access Framework) und
**AES-GCM-verschlüsselt via Android Keystore** gespeichert. Der praktische Weg mit
1Password ist also: Key am Desktop aus 1Password exportieren → aufs Handy übertragen →
in der App importieren. Kein 1Password-Bezug im UI, weil er ehrlicherweise nicht
eingehalten werden könnte.
## Funktionen
- **Hosts** anlegen/bearbeiten/löschen (Name, Host, Port, User, Auth-Art)
- **Authentifizierung** per Passwort oder per importiertem Private Key
- **Keys importieren** (OpenSSH-/PEM-/PKCS8-/Ed25519-Formate via sshj + Bouncy Castle)
- **Verbindung testen** direkt aus dem Host-Editor
- **Interaktives Terminal**: Compose-Canvas-Renderer auf der Termux-`terminal-emulator`
Engine (VT100/xterm, ANSI-Farben, Bold/Italic/Underline, Cursor, Alt-Buffer)
- Tastatur-Erweiterungsleiste (ESC, Tab, Backspace, Pfeile)
- Dunkles, terminal-orientiertes Theme
## Sicherheit
- Sensitive DB-Felder (Passwörter, private Keys) werden mit einem AES-GCM-Schlüssel aus
dem **Android Keystore** ver- und entschlüsselt.
- Klartext steht nur transient im Arbeitsspeicher (während Editieren/Verbinden).
- Host-Verifikation ist aktuell **promiscuous** (MVP) — Known-Hosts-Speicher ist ein
todo.
## Architektur
```
core/crypto KeystoreCrypto (AES-GCM, Android Keystore)
data Room (Host, SshKey, Group) + DAOs + Repositories + DI
domain/model AuthType
ssh SshConnectionManager (sshj), SshKeyParser, SshSupport (Bouncy Castle)
terminal SshTerminalBridge (sshj <-> TerminalEmulator), TerminalTextStyle, KeyEncoder
terminal/ui TerminalCanvas (Compose-Renderer), TerminalViewModel, TerminalScreen
com.termux.terminal.TerminalBufferAccess Adapter auf package-private Buffer-Felder
ui theme, nav, hostlist, hosteditor, keylist, keyimport, components
```
### Terminal-Pipeline
```
SSH-Server ──► sshj Shell.getInputStream()
│ (Reader-Thread)
TerminalEmulator.append(bytes) ← aktualisiert TerminalBuffer
▼ (Revision-Signal)
TerminalCanvas (Compose) liest über TerminalBufferAccess
Tastatur ──► KeyEncoder ──► SshTerminalBridge.send() ──► sshj Shell.getOutputStream()
```
Die Termux-Engine wird **direkt** (ohne `TerminalSession`, das lokale Prozesse startet)
genutzt. Da `TerminalBuffer.mLines`/`TerminalRow.mText` package-private sind, greift
`TerminalBufferAccess` als Klasse im `com.termux.terminal`-Package darauf zu.
## Stack
| Teil | Technologie |
|------|-------------|
| Sprache | Kotlin 2.0.21 (K2) |
| UI | Jetpack Compose (BOM 2024.10), Material 3 |
| DI | Hilt |
| DB | Room 2.6 |
| SSH | sshj 0.39 |
| Krypto | Bouncy Castle 1.78 (Key-Parsing) + Android Keystore (Feldverschlüsselung) |
| Terminal-Engine | Termux `terminal-emulator` 0.118.3 (via JitPack) |
| Build | AGP 8.7.3, Gradle 8.11.1, JDK 17 target / JDK 21 zum Bauen |
| SDK | compileSdk 34, minSdk 26 |
## Bauen
Voraussetzung: Android SDK mit `platforms;android-34` und `build-tools` sowie JDK 17/21.
```bash
# Mit dem Gradle-Wrapper (JDK 21 empfohlen):
JAVA_HOME=/pfad/zum/jdk-21 ./gradlew assembleDebug
# APK liegt dann hier:
app/build/outputs/apk/debug/app-debug.apk
```
Installieren auf einem verbundenen Gerät/Emulator:
```bash
./gradlew installDebug
# oder
adb install app/build/outputs/apk/debug/app-debug.apk
```
## Bekannte Einschränkungen / Todos
- **Passphrase-geschützte Keys**: Beim Import wird die Passphrase geprüft, für das Login
müssen Keys aktuell unverschlüsselt gespeichert werden (MVP).
- **Known-Hosts**: Host-Keys werden noch nicht verifiziert.
- **Hintergrund-Persistenz**: Sessions überleben Konfigurationswechsel (die Activity fängt
`configChanges` ab), aber für echtes Background-Keeping ist ein Foreground-Service
nötig (Folgearchitektur).
- **Terminal-Fidelity**: Ziel ist robuste Shell/`vim`/`htop`-Nutzung; exotische Features
(Sixel, Maus-Reporting, Unicode-Width-Polierung) sind nicht Teil des MVP.
- **Echtes 1Password**: erst möglich, wenn 1Password Android-Support anbietet.

96
app/build.gradle.kts Normal file
View file

@ -0,0 +1,96 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "de.tronax.sshmanager"
compileSdk = 34
defaultConfig {
applicationId = "de.tronax.sshmanager"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
// sshj/Bouncy Castle bringen META-INF-Dateien mit, die beim Merge kollidieren.
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/DEPENDENCIES"
excludes += "/META-INF/INDEX.LIST"
// Bouncy Castle ships duplicate multi-release OSGI manifests across its jars.
excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF"
excludes += "/META-INF/*.SF"
excludes += "/META-INF/*.DSA"
excludes += "/META-INF/*.RSA"
}
}
}
dependencies {
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
// Compose
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// SSH + crypto
implementation(libs.sshj)
implementation(libs.bouncy.castle)
// Terminal emulation
implementation(libs.termux.emulator)
// DataStore
implementation(libs.androidx.datastore.preferences)
}

9
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,9 @@
# Keep sshj/Bouncy Castle reflection entry points.
-keep class com.hierynomus.sshj.** { *; }
-keep class net.schmizz.sshj.** { *; }
-keep class org.bouncycastle.** { *; }
-dontwarn org.bouncycastle.**
-dontwarn org.slf4j.impl.**
# Ed25519 (pure-Java, used by sshj for OpenSSH-v1 keys).
-keep class net.i2p.crypto.eddsa.** { *; }

View file

@ -0,0 +1,31 @@
<?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" />
<application
android:name=".SshManagerApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MobileSshManager"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:theme="@style/Theme.MobileSshManager">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,23 @@
package com.termux.terminal
/**
* Bridge into Termux' package-private rendering fields.
*
* Termux' [TerminalBuffer.mLines] and [TerminalRow.mText]/[TerminalRow.mStyle] are
* package-private they're intended for the bundled `terminal-view` only. Since this app
* ships its own Compose renderer, we place this accessor in the same package
* (`com.termux.terminal`) so it can expose those fields via public, allocation-light getters.
*
* No state of its own; it is a pure forwarding helper.
*/
object TerminalBufferAccess {
/** The rows of a buffer (same backing array; do not mutate). */
fun rows(buffer: TerminalBuffer): Array<TerminalRow> = buffer.mLines
/** The raw char array of a row (do not mutate). */
fun text(row: TerminalRow): CharArray = row.mText
/** Public convenience: char index where a logical column starts in [row]. */
fun startOfColumn(row: TerminalRow, column: Int): Int = row.findStartOfColumn(column)
}

View file

@ -0,0 +1,63 @@
package de.tronax.sshmanager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import dagger.hilt.android.AndroidEntryPoint
import de.tronax.sshmanager.data.settings.SettingsDefaults
import de.tronax.sshmanager.data.settings.SettingsRepository
import de.tronax.sshmanager.data.settings.ThemeMode
import de.tronax.sshmanager.ui.nav.AppNavGraph
import de.tronax.sshmanager.ui.theme.MobileSshManagerTheme
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var settings: SettingsRepository
override fun onCreate(savedInstanceState: Bundle?) {
// Dark, scrim-less edge-to-edge: a terminal app is dark by default.
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(Color.Transparent.hashCode()),
navigationBarStyle = SystemBarStyle.dark(Color.Transparent.hashCode()),
)
super.onCreate(savedInstanceState)
setContent {
val appSettings by settings.settings.collectAsState(
initial = de.tronax.sshmanager.data.settings.AppSettings(
terminalFontSize = SettingsDefaults.TERMINAL_FONT_SIZE_DEFAULT,
themeMode = SettingsDefaults.THEME_DEFAULT,
defaultPort = SettingsDefaults.DEFAULT_PORT,
),
)
// SYSTEM follows the device; DARK/LIGHT override it.
val systemDark = isSystemInDarkTheme()
val darkTheme = when (appSettings.themeMode) {
ThemeMode.SYSTEM -> systemDark
ThemeMode.DARK -> true
ThemeMode.LIGHT -> false
}
MobileSshManagerTheme(darkTheme = darkTheme) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
AppNavGraph()
}
}
}
}
}

View file

@ -0,0 +1,7 @@
package de.tronax.sshmanager
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class SshManagerApp : Application()

View file

@ -0,0 +1,76 @@
package de.tronax.sshmanager.core.crypto
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* Field-level encryption for sensitive DB columns using a hardware-backed
* AES-GCM key in the Android Keystore.
*
* - Plaintext in, ciphertext (Base64) out and vice versa.
* - IV is randomly generated per encrypt() and prepended to the ciphertext.
* - The key never leaves the Keystore (minSdk 26 hardware-backed on most devices).
*/
@Singleton
class KeystoreCrypto @Inject constructor() {
private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
private val secretKey: SecretKey by lazy { getOrCreateKey() }
/** Encrypt UTF-8 plaintext → "Base64(iv || ciphertext)". Returns null for null input. */
fun encrypt(plaintext: String?): String? {
if (plaintext == null) return null
val cipher = Cipher.getInstance(TRANSFORM)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val iv = cipher.iv
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val combined = iv + ct
return Base64.encodeToString(combined, Base64.NO_WRAP)
}
/** Inverse of [encrypt]. Returns null for null/blank input. */
fun decrypt(ciphertext: String?): String? {
if (ciphertext.isNullOrBlank()) return null
val combined = Base64.decode(ciphertext, Base64.NO_WRAP)
val iv = combined.copyOfRange(0, IV_LENGTH)
val ct = combined.copyOfRange(IV_LENGTH, combined.size)
val cipher = Cipher.getInstance(TRANSFORM)
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_BITS, iv))
return cipher.doFinal(ct).toString(Charsets.UTF_8)
}
private fun getOrCreateKey(): SecretKey {
keyStore.getEntry(KEY_ALIAS, null)?.let {
return (it as KeyStore.SecretKeyEntry).secretKey
}
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
gen.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
return gen.generateKey()
}
private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "ssh_manager_field_key"
const val TRANSFORM = "AES/GCM/NoPadding"
const val IV_LENGTH = 12 // bytes (GCM standard)
const val GCM_TAG_BITS = 128
}
}

View file

@ -0,0 +1,31 @@
package de.tronax.sshmanager.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import de.tronax.sshmanager.data.db.dao.HostDao
import de.tronax.sshmanager.data.db.dao.SshKeyDao
import de.tronax.sshmanager.data.db.entity.GroupEntity
import de.tronax.sshmanager.data.db.entity.HostEntity
import de.tronax.sshmanager.data.db.entity.SshKeyEntity
import de.tronax.sshmanager.domain.model.AuthType
class AuthTypeConverter {
@TypeConverter
fun fromAuthType(type: AuthType): String = type.name
@TypeConverter
fun toAuthType(value: String): AuthType = AuthType.valueOf(value)
}
@Database(
entities = [HostEntity::class, SshKeyEntity::class, GroupEntity::class],
version = 1,
exportSchema = false,
)
@TypeConverters(AuthTypeConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun hostDao(): HostDao
abstract fun sshKeyDao(): SshKeyDao
}

View file

@ -0,0 +1,30 @@
package de.tronax.sshmanager.data.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import de.tronax.sshmanager.data.db.entity.HostEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface HostDao {
@Query("SELECT * FROM hosts ORDER BY name COLLATE NOCASE")
fun observeAll(): Flow<List<HostEntity>>
@Query("SELECT * FROM hosts WHERE id = :id")
suspend fun getById(id: Long): HostEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(host: HostEntity): Long
@Update
suspend fun update(host: HostEntity)
@Query("DELETE FROM hosts WHERE id = :id")
suspend fun delete(id: Long)
@Query("UPDATE hosts SET last_connected = :timestamp WHERE id = :id")
suspend fun touchLastConnected(id: Long, timestamp: Long)
}

View file

@ -0,0 +1,26 @@
package de.tronax.sshmanager.data.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import de.tronax.sshmanager.data.db.entity.SshKeyEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SshKeyDao {
@Query("SELECT * FROM ssh_keys ORDER BY name COLLATE NOCASE")
fun observeAll(): Flow<List<SshKeyEntity>>
@Query("SELECT * FROM ssh_keys WHERE id = :id")
suspend fun getById(id: Long): SshKeyEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(key: SshKeyEntity): Long
@Query("DELETE FROM ssh_keys WHERE id = :id")
suspend fun delete(id: Long)
@Query("SELECT COUNT(*) FROM ssh_keys")
suspend fun count(): Int
}

View file

@ -0,0 +1,55 @@
package de.tronax.sshmanager.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import de.tronax.sshmanager.domain.model.AuthType
/**
* A connectable SSH host. Sensitive fields ([passwordCipher]) are stored as
* AES-GCM ciphertext (Base64); plaintext never touches the DB.
*/
@Entity(tableName = "hosts")
data class HostEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val hostName: String,
val port: Int = 22,
val userName: String,
val groupId: Long? = null,
@ColumnInfo(name = "auth_type") val authType: AuthType = AuthType.PASSWORD,
/** Base64 AES-GCM ciphertext of the password, or null if unused. */
@ColumnInfo(name = "password_cipher") val passwordCipher: String? = null,
/** FK to [SshKeyEntity.id], required when authType == KEY. */
@ColumnInfo(name = "key_id") val keyId: Long? = null,
@ColumnInfo(name = "last_connected") val lastConnected: Long? = null,
)
/**
* An imported private key. [privateKeyCipher] is AES-GCM ciphertext (Base64)
* of the original OpenSSH PEM/new-format key text.
*/
@Entity(
tableName = "ssh_keys",
indices = [Index("fingerprint", unique = true)],
)
data class SshKeyEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val keyType: String,
/** SHA-256 fingerprint of the public key, e.g. "SHA256:abc…". */
val fingerprint: String,
@ColumnInfo(name = "private_key_cipher") val privateKeyCipher: String,
@ColumnInfo(name = "has_passphrase") val hasPassphrase: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
)
/** Optional grouping for hosts. */
@Entity(tableName = "groups")
data class GroupEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val sortIndex: Int = 0,
)

View file

@ -0,0 +1,32 @@
package de.tronax.sshmanager.data.di
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.tronax.sshmanager.data.db.AppDatabase
import de.tronax.sshmanager.data.db.dao.HostDao
import de.tronax.sshmanager.data.db.dao.SshKeyDao
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext ctx: Context): AppDatabase =
Room.databaseBuilder(ctx, AppDatabase::class.java, "ssh-manager.db")
// Crypto keys live in the Keystore, so destructive fallback on schema mismatch is
// acceptable for this MVP (no production data yet).
.fallbackToDestructiveMigration()
.build()
@Provides
fun provideHostDao(db: AppDatabase): HostDao = db.hostDao()
@Provides
fun provideSshKeyDao(db: AppDatabase): SshKeyDao = db.sshKeyDao()
}

View file

@ -0,0 +1,77 @@
package de.tronax.sshmanager.data.repo
import de.tronax.sshmanager.core.crypto.KeystoreCrypto
import de.tronax.sshmanager.data.db.dao.HostDao
import de.tronax.sshmanager.data.db.entity.HostEntity
import de.tronax.sshmanager.domain.model.AuthType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/** Host data as the UI sees it: plaintext password is held only transiently in memory. */
data class Host(
val id: Long,
val name: String,
val hostName: String,
val port: Int,
val userName: String,
val groupId: Long?,
val authType: AuthType,
val password: String?, // transient, only set while editing/connecting
val keyId: Long?,
val lastConnected: Long?,
)
@Singleton
class HostRepository @Inject constructor(
private val dao: HostDao,
private val crypto: KeystoreCrypto,
) {
fun observeAll(): Flow<List<Host>> =
dao.observeAll().map { list -> list.map { it.toDomain() } }
suspend fun getById(id: Long): Host? = dao.getById(id)?.toDomain()
/**
* Insert or update. Encrypts the password (if any) before persisting.
* Returns the row id.
*/
suspend fun save(host: Host): Long {
val entity = HostEntity(
id = host.id,
name = host.name.trim(),
hostName = host.hostName.trim(),
port = host.port,
userName = host.userName.trim(),
groupId = host.groupId,
authType = host.authType,
passwordCipher = crypto.encrypt(host.password),
keyId = host.keyId,
lastConnected = host.lastConnected,
)
return if (host.id == 0L) dao.insert(entity) else { dao.update(entity); host.id }
}
suspend fun delete(id: Long) = dao.delete(id)
suspend fun markConnected(id: Long) = dao.touchLastConnected(id, Instant.now().toEpochMilli())
/** Decrypt and return the stored password for this host (for SSH auth). */
suspend fun decryptedPassword(id: Long): String? =
dao.getById(id)?.let { crypto.decrypt(it.passwordCipher) }
private fun HostEntity.toDomain() = Host(
id = id,
name = name,
hostName = hostName,
port = port,
userName = userName,
groupId = groupId,
authType = authType,
password = crypto.decrypt(passwordCipher),
keyId = keyId,
lastConnected = lastConnected,
)
}

View file

@ -0,0 +1,69 @@
package de.tronax.sshmanager.data.repo
import de.tronax.sshmanager.core.crypto.KeystoreCrypto
import de.tronax.sshmanager.data.db.dao.SshKeyDao
import de.tronax.sshmanager.data.db.entity.SshKeyEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/** UI-facing key representation. The private-key material is decrypted only on demand. */
data class SshKey(
val id: Long,
val name: String,
val keyType: String,
val fingerprint: String,
val hasPassphrase: Boolean,
val createdAt: Long,
)
@Singleton
class SshKeyRepository @Inject constructor(
private val dao: SshKeyDao,
private val crypto: KeystoreCrypto,
) {
fun observeAll(): Flow<List<SshKey>> =
dao.observeAll().map { list -> list.map { it.toDomain() } }
suspend fun getById(id: Long): SshKey? = dao.getById(id)?.toDomain()
/**
* Store an imported private key.
*
* @param keyPem the original OpenSSH/PEM key text (with or without passphrase)
* @param passphrase an optional passphrase hint is stored separately via [hasPassphrase]
* @return the new row id
*/
suspend fun save(
name: String,
keyType: String,
fingerprint: String,
keyPem: String,
hasPassphrase: Boolean,
): Long {
val entity = SshKeyEntity(
name = name.trim(),
keyType = keyType,
fingerprint = fingerprint,
privateKeyCipher = crypto.encrypt(keyPem) ?: error("encryption failed"),
hasPassphrase = hasPassphrase,
)
return dao.insert(entity)
}
suspend fun delete(id: Long) = dao.delete(id)
/** Decrypt the raw key text for use by the SSH layer (kept in memory only). */
suspend fun decryptedKeyText(id: Long): String? =
dao.getById(id)?.let { crypto.decrypt(it.privateKeyCipher) }
private fun SshKeyEntity.toDomain() = SshKey(
id = id,
name = name,
keyType = keyType,
fingerprint = fingerprint,
hasPassphrase = hasPassphrase,
createdAt = createdAt,
)
}

View file

@ -0,0 +1,80 @@
package de.tronax.sshmanager.data.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/** App-wide theme preference. */
enum class ThemeMode { SYSTEM, DARK, LIGHT }
/**
* Persisted, non-sensitive app settings backed by Jetpack DataStore (Preferences).
*
* DataStore replaces a host's key flow on writes and emits the new value on read, so the
* UI can react to changes made anywhere in the app. All keys live under one DataStore
* instance `settings` scoped to the application context via the top-level extension below.
*/
data class AppSettings(
val terminalFontSize: Int, // sp, 8..24
val themeMode: ThemeMode,
val defaultPort: Int,
)
/** Default values used on first launch and as the source of truth for valid ranges. */
object SettingsDefaults {
const val TERMINAL_FONT_SIZE_MIN = 8
const val TERMINAL_FONT_SIZE_MAX = 24
const val TERMINAL_FONT_SIZE_DEFAULT = 13
val THEME_DEFAULT = ThemeMode.SYSTEM
const val DEFAULT_PORT = 22
}
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@Singleton
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
private object Keys {
val TERMINAL_FONT_SIZE = intPreferencesKey("terminal_font_size")
val THEME_MODE = stringPreferencesKey("theme_mode")
val DEFAULT_PORT = intPreferencesKey("default_port")
}
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { prefs ->
AppSettings(
terminalFontSize = prefs[Keys.TERMINAL_FONT_SIZE]
?: SettingsDefaults.TERMINAL_FONT_SIZE_DEFAULT,
themeMode = prefs[Keys.THEME_MODE]
?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
?: SettingsDefaults.THEME_DEFAULT,
defaultPort = prefs[Keys.DEFAULT_PORT] ?: SettingsDefaults.DEFAULT_PORT,
)
}
suspend fun setTerminalFontSize(sp: Int) {
val clamped = sp.coerceIn(
SettingsDefaults.TERMINAL_FONT_SIZE_MIN,
SettingsDefaults.TERMINAL_FONT_SIZE_MAX,
)
context.settingsDataStore.edit { it[Keys.TERMINAL_FONT_SIZE] = clamped }
}
suspend fun setThemeMode(mode: ThemeMode) {
context.settingsDataStore.edit { it[Keys.THEME_MODE] = mode.name }
}
suspend fun setDefaultPort(port: Int) {
val clamped = port.coerceIn(1, 65535)
context.settingsDataStore.edit { it[Keys.DEFAULT_PORT] = clamped }
}
}

View file

@ -0,0 +1,7 @@
package de.tronax.sshmanager.domain.model
/** How a host authenticates. Persisted as its name in Room. */
enum class AuthType { PASSWORD, KEY }
/** Edit vs. create marker; 0 means "new host". */
const val NEW_HOST_ID = 0L

View file

@ -0,0 +1,115 @@
package de.tronax.sshmanager.ssh
import de.tronax.sshmanager.data.repo.Host
import de.tronax.sshmanager.data.repo.HostRepository
import de.tronax.sshmanager.data.repo.SshKeyRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.connection.channel.direct.Session
import net.schmizz.sshj.transport.verification.PromiscuousVerifier
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
import javax.inject.Singleton
/** Raised when connecting or authenticating fails. */
class SshConnectionException(message: String, cause: Throwable? = null) :
IOException(message, cause)
/**
* Owns the lifecycle of an [SSHClient] connection and provides a typed handle
* ([Connection]) exposing the live shell streams for the terminal layer.
*
* Host-key verification is currently promiscuous (MVP). TODO: add known-hosts store.
*/
@Singleton
class SshConnectionManager @Inject constructor(
private val hosts: HostRepository,
private val keys: SshKeyRepository,
) {
/**
* Open and authenticate a connection. Returns a [Connection] whose [Session.Shell]
* is ready for the terminal bridge to read/write. Call [Connection.close] when done.
*/
suspend fun connect(host: Host): Connection = withContext(Dispatchers.IO) {
SshSupport.ensureProviders()
val client = SSHClient()
// TODO: persist + verify known host fingerprints instead of accepting all.
client.addHostKeyVerifier(PromiscuousVerifier())
client.connectTimeout = CONNECT_TIMEOUT_MS
client.timeout = READ_TIMEOUT_MS
try {
client.connect(host.hostName, host.port)
authenticate(client, host)
} catch (e: Exception) {
try { client.close() } catch (_: Exception) {}
throw SshConnectionException(
"Verbindung zu ${host.userName}@${host.hostName}:${host.port} fehlgeschlagen: ${e.message}",
e,
)
}
hosts.markConnected(host.id)
Connection(client)
}
private suspend fun authenticate(client: SSHClient, host: Host) {
val username = host.userName
when (host.authType) {
de.tronax.sshmanager.domain.model.AuthType.PASSWORD -> {
val pw = host.password
?: hosts.decryptedPassword(host.id)
?: throw SshConnectionException("Kein Passwort gespeichert für '${host.name}'.")
client.authPassword(username, pw)
}
de.tronax.sshmanager.domain.model.AuthType.KEY -> {
val keyId = host.keyId ?: throw SshConnectionException("Kein Schlüssel zugeordnet.")
val pem = keys.decryptedKeyText(keyId)
?: throw SshConnectionException("Schlüssel konnte nicht entschlüsselt werden.")
// Re-prompt for passphrase is not supported in this path; keys used for login
// must either be unencrypted or — for now — imported without passphrase.
val kp = SshSupport.keyProviderFor(pem, null)
client.authPublickey(username, kp)
}
}
}
/** Live SSH connection wrapping an [SSHClient] and an open shell [Session.Shell]. */
class Connection(internal val client: SSHClient) : AutoCloseable {
private var session: Session? = null
private var shell: Session.Shell? = null
/** Allocates a PTY shell (vt220, 80x24 default; the terminal resizes later). */
fun startShell(): ShellHandle {
val s = client.startSession().also { session = it }
s.allocateDefaultPTY() // vt100-ish; termios refined by the emulator feed
val sh = s.startShell().also { shell = it }
return ShellHandle(
remoteOutput = sh.getInputStream(), // bytes coming FROM the server
remoteInput = sh.getOutputStream(), // bytes going TO the server
close = { close() },
)
}
override fun close() {
try { shell?.close() } catch (_: Exception) {}
try { session?.close() } catch (_: Exception) {}
try { client.disconnect() } catch (_: Exception) {}
}
}
/** Minimal, terminal-agnostic handle to the live shell's two streams. */
data class ShellHandle(
val remoteOutput: InputStream, // bytes coming FROM the server (→ emulator)
val remoteInput: OutputStream, // bytes going TO the server (keyboard)
val close: () -> Unit,
)
private companion object {
const val CONNECT_TIMEOUT_MS = 15_000
const val READ_TIMEOUT_MS = 0 // 0 = no socket read timeout for interactive shell
}
}

View file

@ -0,0 +1,97 @@
package de.tronax.sshmanager.ssh
import java.io.IOException
/** Result of validating/parsing a private key before storage. */
data class ParsedKey(
val keyType: String,
/** "SHA256:base64…" fingerprint of the public key. */
val fingerprint: String,
/** True if the key material is passphrase-protected and a passphrase was needed to parse. */
val hasPassphrase: Boolean,
)
/**
* Validates imported private keys using sshj's [net.schmizz.sshj.userauth.keyprovider.KeyProvider]
* via [SshSupport], and derives a stable fingerprint so duplicate imports can be detected.
*
* Bouncy Castle is registered as a JCA provider inside [SshSupport] so OpenSSH new-format
* and Ed25519 keys parse on Android.
*/
class SshKeyParser {
/**
* Validate a key and derive type/fingerprint.
*
* sshj throws its parsing errors lazily sometimes only on the first
* `getPublic()` call, not during `keyProviderFor`. So we wrap the whole
* parse path and translate sshj's raw exceptions into clear messages.
*
* @throws IOException if the key is invalid, the passphrase is wrong, or the format
* is not supported (e.g. Ed25519 in PKCS#8).
*/
fun parse(keyPem: String, passphrase: CharArray? = null): ParsedKey {
val hadPassphrase = passphrase != null && passphrase.isNotEmpty()
try {
val kp = SshSupport.keyProviderFor(keyPem, passphrase)
return ParsedKey(
keyType = SshSupport.typeOf(kp),
fingerprint = SshSupport.fingerprintOf(kp),
hasPassphrase = hadPassphrase,
)
} catch (e: IllegalArgumentException) {
if (e.message?.contains("1.3.101.112") == true || e.message?.contains("EdDSA") == true) {
throw IOException(ed25519Help(keyPem), e)
}
throw IOException(e.message ?: "Schluessel ungueltig.", e)
} catch (e: Exception) {
// Re-throw IOExceptions as-is; everything else becomes a friendly message.
throw if (e is IOException) e
else IOException(e.message ?: "Schluessel konnte nicht gelesen werden.", e)
}
}
/**
* Ed25519 keys must be in the OpenSSH new-format container
* (-----BEGIN OPENSSH PRIVATE KEY-----). sshj 0.39 cannot read Ed25519 from PKCS#8.
* This helper produces a user-facing hint, including whether the current key is the
* wrong (PKCS#8) format.
*/
private fun ed25519Help(keyPem: String): String {
val isOpenSsh = keyPem.contains("BEGIN OPENSSH PRIVATE KEY")
return if (isOpenSsh) {
"Ed25519-Schluessel konnte nicht gelesen werden (Passphrase falsch?)."
} else {
"Ed25519 wird nur im OpenSSH-Format unterstuetzt " +
"(Header 'BEGIN OPENSSH PRIVATE KEY'). Dein Key liegt als " +
"'BEGIN PRIVATE KEY' (PKCS#8) vor und kann nicht gelesen werden.\n" +
"Konvertiere ihn am PC: ssh-keygen -p -m RFC4716 -f <key> " +
"(oder neu erzeugen mit 'ssh-keygen -t ed25519', Standardformat)."
}
}
/**
* Guess whether a key is likely passphrase-protected by trying to read it
* without a passphrase first. Used by the import UI to decide whether to ask.
*
* Robust against any failure (format, algorithm, parsing) returns false then,
* since the actual error will surface during [parse].
*/
fun needsPassphrase(keyPem: String): Boolean =
try {
// Force header check so we don't trigger sshj's lazy Ed25519 crash here.
SshSupport.keyProviderFor(keyPem, null)
// Also probe the public key, because sshj parses lazily on getPublic().
// We do this in a second try so a non-passphrase failure does not force a prompt.
try {
SshSupport.probePublicOrThrow(keyPem)
false
} catch (_: Exception) {
// If probing without passphrase fails but the format is OpenSSH-v1, it's
// almost certainly a passphrase issue.
keyPem.contains("BEGIN OPENSSH PRIVATE KEY")
}
} catch (_: Exception) {
false
}
}

View file

@ -0,0 +1,146 @@
package de.tronax.sshmanager.ssh
import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile
import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.common.SecurityUtils
import net.schmizz.sshj.userauth.keyprovider.KeyProvider
import net.schmizz.sshj.userauth.password.PasswordFinder
import net.schmizz.sshj.userauth.password.Resource
import org.bouncycastle.jce.provider.BouncyCastleProvider
import java.io.IOException
import java.io.StringReader
import java.security.Security
/**
* Bridges sshj key loading with our in-memory key text.
*
* **Ed25519 note:** sshj 0.39 supports Ed25519 only via the OpenSSH-v1 container
* (`-----BEGIN OPENSSH PRIVATE KEY-----`), which it parses internally with the pure-Java
* `net.i2p.crypto:eddsa` library no JCA EdDSA provider needed (works on Android API 26).
* It does NOT support Ed25519 in PKCS#8 form (`-----BEGIN PRIVATE KEY-----`); that path
* throws "Unsupported Algorithm [1.3.101.112]". So we dispatch on the header and route
* OpenSSH-v1 keys to [OpenSSHKeyV1KeyFile] explicitly, while RSA/ECDSA/DSA in PKCS#8/PEM
* go through [SSHClient.loadKeys].
*/
object SshSupport {
@Volatile private var providersInstalled = false
/**
* Register Bouncy Castle as a JCA provider (needed for parsing some key formats) but
* keep sshj's own SecurityUtils provider at null.
*
* Why: sshj's [SecurityUtils] auto-registers BC as *its* provider when BC is on the
* classpath, then routes every JCA call (KeyExchange/KeyAgreement/KeyFactory/Signature)
* through `getInstance(alg, "BC")`. BC 1.78 does not implement X25519 for KeyAgreement
* nor MD5, which caused "no such algorithm: X25519 for provider BC" during key exchange
* (and the earlier MD5 fingerprint failure). Forcing the provider back to null makes
* sshj use the platform default JCA which on Android supports the modern KEX
* algorithms while BC stays available for explicit key-parsing lookups.
*/
fun ensureProviders() {
if (providersInstalled) return
synchronized(this) {
if (providersInstalled) return
if (Security.getProvider("BC") == null) {
Security.addProvider(BouncyCastleProvider())
}
// Prevent sshj from auto-binding BC as its security provider. setSecurityProvider
// alone is not enough — register() re-binds BC as long as registerBouncyCastle
// is null/unset. We must explicitly disable the auto-registration.
SecurityUtils.setRegisterBouncyCastle(false)
SecurityUtils.setSecurityProvider(null)
providersInstalled = true
}
}
private const val OPENSSH_V1_HEADER = "BEGIN OPENSSH PRIVATE KEY"
private const val PKCS8_HEADER = "BEGIN PRIVATE KEY"
/**
* Build a [KeyProvider] for the given key text, choosing the right parser by header.
*
* @throws IOException if the key is malformed, the passphrase is wrong, or an Ed25519
* key is supplied in the unsupported PKCS#8 container.
*/
fun keyProviderFor(keyPem: String, passphrase: CharArray? = null): KeyProvider {
ensureProviders()
val pwf = passwordFinder(passphrase)
// OpenSSH new-format container → use the v1 reader that supports Ed25519.
if (keyPem.contains(OPENSSH_V1_HEADER)) {
return OpenSSHKeyV1KeyFile().apply {
init(StringReader(keyPem), null, pwf)
}
}
// Anything else (PKCS#8 PEM, traditional PEM, PuTTY) → let sshj's loadKeys pick.
// This covers RSA/ECDSA/DSA. Ed25519 cannot be read here (no sshj support in PKCS#8),
// which we surface as a friendly error below instead of letting sshj throw raw.
return try {
SSHClient().loadKeys(keyPem, null, pwf)
} catch (e: IllegalArgumentException) {
if (e.message?.contains("1.3.101.112") == true) {
throw IOException(
"Ed25519-Schluessel wird nur im OpenSSH-Format unterstuetzt " +
"(Header: BEGIN OPENSSH PRIVATE KEY). Ein Ed25519-Key im " +
"PKCS#8-Format (BEGIN PRIVATE KEY) kann nicht gelesen werden. " +
"Erzeuge den Key ohne -m PEM, also im Standardformat.", e,
)
}
throw IOException(e.message ?: "Schluessel konnte nicht gelesen werden.", e)
}
}
private fun passwordFinder(passphrase: CharArray?): PasswordFinder? =
if (passphrase != null && passphrase.isNotEmpty()) {
object : PasswordFinder {
override fun reqPassword(resource: Resource<*>?) = passphrase.copyOf()
override fun shouldRetry(resource: Resource<*>?) = false
}
} else {
null
}
/**
* OpenSSH-format SHA256 fingerprint of a key's public part ("SHA256:base64…"),
* matching `ssh-keygen -lf` and 1Password.
*
* We compute it ourselves rather than calling [SecurityUtils.getFingerprint], because
* the latter uses legacy MD5 and routes through the registered security provider
* which fails with "no such algorithm: MD5 for provider BC" under Bouncy Castle 1.78.
* The fingerprint is SHA-256 over the SSH wire encoding of the public key, Base64.
*/
fun fingerprintOf(keyProvider: KeyProvider): String {
val pub = keyProvider.public ?: throw IOException("Key has no public part")
val wire = sshWireEncoding(pub)
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(wire)
val b64 = android.util.Base64.encodeToString(digest, android.util.Base64.NO_WRAP)
return "SHA256:$b64"
}
/**
* SSH wire encoding of a public key (as used inside the SSH protocol and for
* fingerprinting). sshj exposes this via Buffer.PlainBuffer#putPublicKey.
*/
private fun sshWireEncoding(pub: java.security.PublicKey): ByteArray {
val buf = net.schmizz.sshj.common.Buffer.PlainBuffer()
buf.putPublicKey(pub)
return buf.array().copyOfRange(buf.rpos(), buf.wpos())
}
/**
* Force sshj to actually parse the key now (it is lazy). sshj reads the private key
* only when [KeyProvider.getPublic] (or [KeyProvider.getPrivate]) is first called, so
* errors surface there. This probe exists so callers can detect bad keys/passphrases
* before persistence.
*/
fun probePublicOrThrow(keyPem: String, passphrase: CharArray? = null) {
val kp = keyProviderFor(keyPem, passphrase)
kp.public // triggers lazy parse; may throw IllegalArgumentException/IOException
}
/** Algorithm of the public key, e.g. "RSA", "EC", "EdDSA", "DSA". */
fun typeOf(keyProvider: KeyProvider): String =
(keyProvider.public ?: throw IOException("Key has no public part")).algorithm
}

View file

@ -0,0 +1,16 @@
package de.tronax.sshmanager.ssh.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.tronax.sshmanager.ssh.SshKeyParser
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SshModule {
@Provides
@Singleton
fun provideSshKeyParser(): SshKeyParser = SshKeyParser()
}

View file

@ -0,0 +1,36 @@
package de.tronax.sshmanager.terminal
/**
* Translates high-level key intents (arrows, function keys, control chars) into the
* byte sequences a Unix PTY expects. Plain printable characters are sent verbatim by
* the caller; this covers the special keys surfaced by the on-screen keyboard bar.
*/
object KeyEncoder {
const val ESC = "\u001B"
// Application cursor mode would use ESC O instead of ESC [, but the vast majority of
// modern shells use normal mode, so we emit the CSI sequences.
val ARROW_UP = "$ESC[A"
val ARROW_DOWN = "$ESC[B"
val ARROW_RIGHT = "$ESC[C"
val ARROW_LEFT = "$ESC[D"
val HOME = "$ESC[H"
val END = "$ESC[F"
val DELETE = "$ESC[3~"
val PAGE_UP = "$ESC[5~"
val PAGE_DOWN = "$ESC[6~"
val TAB = "\t"
val ENTER = "\r"
val BACKSPACE = "\u007F" // DEL — what most shells/line-editors expect from Backspace
/** Map a single char to Ctrl-<char>: the control code is char bitwise-AND 0x1F. */
fun ctrl(c: Char): String {
val lower = c.lowercaseChar()
val code = lower.code and 0x1F
return String(Character.toChars(code))
}
val ESCAPE = ESC
}

View file

@ -0,0 +1,117 @@
package de.tronax.sshmanager.terminal
import com.termux.terminal.TerminalEmulator
import de.tronax.sshmanager.ssh.SshConnectionManager
import java.io.IOException
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* Bidirectional bridge between an SSH shell and a Termux [TerminalEmulator].
*
* - Bytes FROM the server are fed into [TerminalEmulator.append], which advances the
* emulator state machine and mutates the internal [com.termux.terminal.TerminalBuffer].
* - The emulator's responses (e.g. cursor-position reports) and keyboard input go back
* TO the server via [TerminalOutput.write], implemented here to write the SSH stream.
*
* The emulator is created with a null [com.termux.terminal.TerminalSessionClient]; all UI
* signaling is done via [onChanged].
*/
class SshTerminalBridge(
private val handle: SshConnectionManager.ShellHandle,
private val columns: Int,
private val rows: Int,
/** Invoked on the calling thread whenever the screen changed and should be re-rendered. */
private val onChanged: () -> Unit,
) : AutoCloseable {
private val closed = AtomicBoolean(false)
private var readerThread: Thread? = null
/**
* Single-threaded executor for writing to the SSH socket. Network IO must not happen
* on the main thread (Android throws [android.os.NetworkOnMainThreadException]); a
* single worker also preserves the strict ordering of keyboard input.
*/
private val writer = Executors.newSingleThreadExecutor { r ->
Thread(r, "ssh-terminal-writer").apply { isDaemon = true }
}
/** Sink for emulator-generated output (responses + keyboard input). Declared before
* [emulator] since the latter captures it in its constructor. */
private val output: com.termux.terminal.TerminalOutput = object : com.termux.terminal.TerminalOutput() {
override fun write(data: ByteArray, offset: Int, count: Int) {
send(data.copyOfRange(offset, offset + count))
}
override fun titleChanged(oldTitle: String, newTitle: String) {}
override fun onCopyTextToClipboard(text: String) {}
override fun onPasteTextFromClipboard() {}
override fun onBell() {}
override fun onColorsChanged() {}
}
/** The emulator. Access its screen via [.screen]. */
val emulator: TerminalEmulator = TerminalEmulator(output, columns, rows, 1, 1, null, null)
/**
* Forward bytes typed in the UI to the remote shell. Runs the socket write on a
* background thread calling this from the main thread is safe.
*/
fun send(data: ByteArray) {
if (closed.get()) return
writer.execute {
if (closed.get()) return@execute
try {
handle.remoteInput.write(data)
handle.remoteInput.flush()
} catch (_: IOException) {
// Connection likely closed; the reader loop will surface termination.
}
}
}
/** Send a UTF-8 string (convenience for the keyboard bar). */
fun send(text: String) = send(text.toByteArray(Charsets.UTF_8))
/** Resize the emulator + notify the remote PTY (best-effort). */
fun resize(newColumns: Int, newRows: Int) {
emulator.resize(newColumns, newRows, 1, 1)
// sshj does not expose PTY window-change on Shell directly; sshd may not reflow,
// but local rendering stays correct.
}
/** Start pumping server bytes into the emulator on a background thread. */
fun start() {
readerThread = Thread({ pump() }, "ssh-terminal-reader").apply {
isDaemon = true
start()
}
}
private fun pump() {
val buf = ByteArray(8192)
try {
while (!closed.get()) {
val read = handle.remoteOutput.read(buf)
if (read <= 0) break
synchronized(emulator) {
emulator.append(buf, read)
}
onChanged()
}
} catch (_: IOException) {
// Stream closed — surface a final empty update so the UI can detect EOF.
} finally {
onChanged()
}
}
override fun close() {
if (!closed.compareAndSet(false, true)) return
readerThread?.interrupt()
// Stop accepting new writes and let pending ones finish.
writer.shutdown()
handle.close()
}
}

View file

@ -0,0 +1,79 @@
package de.tronax.sshmanager.terminal
import androidx.compose.ui.graphics.Color
import com.termux.terminal.TextStyle
/**
* Decodes Termux' packed 64-bit cell style into foreground/background colors and effects.
*
* The encoding (see Termux `TextStyle.java`, v0.118.x) packs into a single `long`:
* - bits 0..10 effect flags (bold/italic/underline/inverse/.../truecolor-fg/truecolor-bg)
* - bits 16..39 background color (9-bit palette index OR 24-bit ARGB when truecolor flag set)
* - bits 40..63 foreground color (same scheme)
*
* Index values 0..255 are the 256-color palette, 256 = default foreground, 257 = default
* background, 258 = cursor. These special indices are resolved through the emulator's live
* color array (`TerminalEmulator.mColors.mCurrentColors`), so user-defined ANSI themes and
* SGR redefinitions are honored automatically we never keep our own static palette here.
*/
data class DecodedStyle(
val foreground: Color,
val background: Color,
val bold: Boolean,
val italic: Boolean,
val underline: Boolean,
val inverse: Boolean,
)
object TerminalTextStyle {
/** Termux palette indices for the "default" foreground/background/cursor slots. */
const val COLOR_INDEX_FOREGROUND = TextStyle.COLOR_INDEX_FOREGROUND // 256
const val COLOR_INDEX_BACKGROUND = TextStyle.COLOR_INDEX_BACKGROUND // 257
/**
* Decode a packed cell style. [palette] is the emulator's current color table
* (`emulator.mColors.mCurrentColors`); indices that map outside it fall back to
* sensible defaults so rendering never throws.
*/
fun decode(style: Long, palette: IntArray): DecodedStyle {
val effect = TextStyle.decodeEffect(style)
val bold = effect and TextStyle.CHARACTER_ATTRIBUTE_BOLD != 0
val italic = effect and TextStyle.CHARACTER_ATTRIBUTE_ITALIC != 0
val underline = effect and TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE != 0
val inverse = effect and TextStyle.CHARACTER_ATTRIBUTE_INVERSE != 0
val rawFg = resolveColor(TextStyle.decodeForeColor(style), palette)
val rawBg = resolveColor(TextStyle.decodeBackColor(style), palette)
// Inverse video swaps foreground/background.
val fg = if (inverse) rawBg else rawFg
val bg = if (inverse) rawFg else rawBg
return DecodedStyle(
foreground = fg,
background = bg,
bold = bold,
italic = italic,
underline = underline,
inverse = inverse,
)
}
/**
* Map a decoded Termux color int to a Compose [Color].
*
* `color` is either a 24-bit ARGB value (`0xFFrrggbb`, returned directly) or a 9-bit
* palette index (0..258) that we look up in the live emulator palette. Indices beyond
* the table are clamped to the default foreground/background slots.
*/
private fun resolveColor(color: Int, palette: IntArray): Color {
val argb = if (color ushr 24 == 0xFF) {
// 24-bit true color already carries full alpha.
color
} else {
// Indexed: 0..255 palette, 256/257/258 are default fg/bg/cursor slots.
val safeIndex = if (color in palette.indices) color else COLOR_INDEX_FOREGROUND
palette[safeIndex]
}
return Color(argb)
}
}

View file

@ -0,0 +1,175 @@
package de.tronax.sshmanager.terminal.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.termux.terminal.TerminalBufferAccess
import de.tronax.sshmanager.terminal.SshTerminalBridge
import de.tronax.sshmanager.terminal.TerminalTextStyle
/**
* Renders the terminal buffer onto a Compose [Canvas] and automatically resizes
* the emulator/PTY grid ([SshTerminalBridge.resize]) to fit the exact width/height.
*
* Colors are decoded from each cell's packed style and resolved through the emulator's
* live palette, so default text (foreground/background index 256/257) renders correctly
* and ANSI themes applied by the server are honored.
*
* @param fontSize Terminal font size in sp; sourced from user settings.
*/
@Composable
fun TerminalCanvas(
bridge: SshTerminalBridge,
revision: Long,
modifier: Modifier = Modifier,
fontSize: TextUnit = 13.sp,
) {
val density = LocalDensity.current
val textSizePx = with(density) { fontSize.toPx() }
// The Paint used both for measuring cell width and for drawing glyphs — keeping them
// consistent avoids a mismatch where glyphs render at a different advance than the grid.
// `remember(textSizePx)` ties both the Paint and the measured metrics to the same key,
// so a font-size change always rebuilds paint + metrics together.
val paint = remember(textSizePx) {
Paint().asFrameworkPaint().apply {
isAntiAlias = true
typeface = android.graphics.Typeface.MONOSPACE
textSize = textSizePx
}
}
val metrics = remember(textSizePx) {
paint.textSize = textSizePx
val width = paint.measureText("M")
val fm = paint.fontMetrics
val height = (fm.descent - fm.ascent)
CellMetrics(width = width, height = height)
}
var lastSize by remember { mutableStateOf(IntSize.Zero) }
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val wPx = with(density) { maxWidth.toPx() }
val hPx = with(density) { maxHeight.toPx() }
val fitCols = (wPx / metrics.width).toInt().coerceIn(20, 200)
val fitRows = (hPx / metrics.height).toInt().coerceIn(5, 100)
val newSize = IntSize(fitCols, fitRows)
LaunchedEffect(newSize) {
if (newSize != lastSize) {
lastSize = newSize
bridge.resize(fitCols, fitRows)
}
}
Canvas(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF0F1419)),
) {
// Reading `revision` here makes the redraw a read-state effect of the revision
// flow, so Compose schedules a new draw every time the bridge signals a change.
// It is read into `tick` to make the dependency explicit and avoid it being
// optimised away.
@Suppress("UNUSED_VARIABLE")
val tick = revision
val em = bridge.emulator
val screen = em.screen
val palette = em.mColors.mCurrentColors
// Default background = palette slot 257, used to skip drawing redundant cell bg.
val defaultBg = Color(palette.getOrElse(TerminalTextStyle.COLOR_INDEX_BACKGROUND) { 0xFF0F1419.toInt() })
drawRect(color = defaultBg, size = size)
val cols = em.mColumns
val rows = em.mRows
val cw = metrics.width
val ch = metrics.height
val baseline = ch * 0.82f
val lines = TerminalBufferAccess.rows(screen)
for (row in 0 until rows) {
val line = try {
lines[screen.externalToInternalRow(row)]
} catch (_: Exception) {
null
} ?: continue
val chars = TerminalBufferAccess.text(line)
for (col in 0 until cols) {
val decoded = TerminalTextStyle.decode(line.getStyle(col), palette)
if (decoded.background != defaultBg) {
drawRect(
color = decoded.background,
topLeft = Offset(col * cw, row * ch),
size = Size(cw, ch),
)
}
val startIdx = safeStart(line, col)
val endIdx = safeStart(line, col + 1)
if (endIdx > startIdx && startIdx < chars.size) {
val len = (endIdx - startIdx).coerceAtMost(chars.size - startIdx)
if (len > 0) {
paint.color = decoded.foreground.toArgb()
paint.isFakeBoldText = decoded.bold
paint.isUnderlineText = decoded.underline
paint.textSkewX = if (decoded.italic) -0.2f else 0f
val glyph = String(chars, startIdx, len)
drawIntoCanvas { c ->
c.nativeCanvas.drawText(
glyph,
col * cw,
row * ch + baseline,
paint,
)
}
}
}
}
}
// Draw cursor.
val cRow = em.cursorRow
val cCol = em.cursorCol
if (cRow in 0 until rows && cCol in 0 until cols) {
drawRect(
color = Color(0x66FFFFFF),
topLeft = Offset(cCol * cw, cRow * ch),
size = Size(cw, ch),
)
}
}
}
}
private fun safeStart(line: com.termux.terminal.TerminalRow, column: Int): Int =
try {
TerminalBufferAccess.startOfColumn(line, column)
} catch (_: Exception) {
TerminalBufferAccess.text(line).size
}
private data class CellMetrics(val width: Float, val height: Float)

View file

@ -0,0 +1,217 @@
package de.tronax.sshmanager.terminal.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import de.tronax.sshmanager.terminal.KeyEncoder
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TerminalScreen(
onClose: () -> Unit,
viewModel: TerminalViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) { viewModel.connect() }
val state by viewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Terminal") },
navigationIcon = {
IconButton(onClick = {
viewModel.disconnect()
onClose()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Schließen")
}
},
)
},
) { padding ->
Box(
Modifier
.fillMaxSize()
.padding(padding),
) {
when (val s = state) {
TerminalUiState.Connecting -> CenterLoading("Verbinde…")
is TerminalUiState.Error -> CenterError(s.message, onClose)
TerminalUiState.Closed -> CenterMessage("Verbindung getrennt.", onClose)
is TerminalUiState.Connected -> TerminalBody(
bridge = s.bridge,
revision = viewModel.revision.collectAsState().value,
fontSize = viewModel.fontSize.collectAsState().value,
onSend = viewModel::send,
)
}
}
}
}
@Composable
private fun TerminalBody(
bridge: de.tronax.sshmanager.terminal.SshTerminalBridge,
revision: Long,
fontSize: Int,
onSend: (String) -> Unit,
) {
// revision is passed down to TerminalCanvas so it recomposes on screen changes.
var input by remember { mutableStateOf(TextFieldValue("")) }
Column(Modifier.fillMaxSize()) {
Box(
Modifier
.weight(1f)
.fillMaxWidth(),
) {
TerminalCanvas(
bridge = bridge,
revision = revision,
fontSize = fontSize.sp,
modifier = Modifier.fillMaxSize(),
)
}
// Input line: type + send Enter.
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Eingabe…") },
singleLine = true,
)
Spacer(Modifier.width(8.dp))
IconButton(onClick = {
onSend(input.text + KeyEncoder.ENTER)
input = TextFieldValue("")
}) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden")
}
}
KeyBar(onSend)
Spacer(Modifier.height(4.dp))
}
}
@Composable
private fun KeyBar(onSend: (String) -> Unit) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
KeyBarButton("ESC", KeyEncoder.ESCAPE, onSend)
KeyBarButton("Tab", KeyEncoder.TAB, onSend)
KeyBarButton("", KeyEncoder.BACKSPACE, onSend)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
IconButton(onClick = { onSend(KeyEncoder.ARROW_UP) }) {
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = "Hoch")
}
IconButton(onClick = { onSend(KeyEncoder.ARROW_DOWN) }) {
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = "Runter")
}
IconButton(onClick = { onSend(KeyEncoder.ARROW_LEFT) }) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Links")
}
IconButton(onClick = { onSend(KeyEncoder.ARROW_RIGHT) }) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Rechts")
}
}
}
}
@Composable
private fun KeyBarButton(
label: String,
sequence: String,
onSend: (String) -> Unit,
) {
FilledTonalButton(onClick = { onSend(sequence) }) { Text(label) }
}
@Composable
private fun CenterLoading(text: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(Modifier.height(8.dp))
Text(text)
}
}
}
@Composable
private fun CenterMessage(text: String, onClose: () -> Unit) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text)
Spacer(Modifier.height(8.dp))
TextButton(onClick = onClose) { Text("Zurück") }
}
}
}
@Composable
private fun CenterError(message: String, onClose: () -> Unit) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(24.dp),
) {
Text("Fehler", style = androidx.compose.material3.MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(message)
Spacer(Modifier.height(16.dp))
TextButton(onClick = onClose) { Text("Zurück") }
}
}
}

View file

@ -0,0 +1,100 @@
package de.tronax.sshmanager.terminal.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.tronax.sshmanager.data.repo.HostRepository
import de.tronax.sshmanager.data.settings.SettingsDefaults
import de.tronax.sshmanager.data.settings.SettingsRepository
import de.tronax.sshmanager.ssh.SshConnectionException
import de.tronax.sshmanager.ssh.SshConnectionManager
import de.tronax.sshmanager.terminal.SshTerminalBridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Drives one SSH session: connects, exposes the bridge, and tracks lifecycle/errors. */
@HiltViewModel
class TerminalViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val hosts: HostRepository,
private val connections: SshConnectionManager,
settings: SettingsRepository,
) : ViewModel() {
private val hostId: Long = savedStateHandle["hostId"] ?: 0L
private val _state = MutableStateFlow<TerminalUiState>(TerminalUiState.Connecting)
val state: StateFlow<TerminalUiState> = _state.asStateFlow()
// Bumped each time the bridge signals a screen change; the Canvas observes it.
private val _revision = MutableStateFlow(0L)
val revision: StateFlow<Long> = _revision.asStateFlow()
// Terminal font size (sp) sourced from settings so the Canvas renders at the user's
// chosen size; the Canvas recomputes its cell grid when this changes.
val fontSize: StateFlow<Int> = settings.settings
.map { it.terminalFontSize }
.stateIn(viewModelScope, SharingStarted.Eagerly, SettingsDefaults.TERMINAL_FONT_SIZE_DEFAULT)
private var bridgeRef: SshTerminalBridge? = null
fun connect() {
if (bridgeRef != null) return
viewModelScope.launch(Dispatchers.IO) {
val host = hosts.getById(hostId) ?: run {
_state.value = TerminalUiState.Error("Host nicht gefunden.")
return@launch
}
try {
val conn = connections.connect(host)
val bridge = SshTerminalBridge(
handle = conn.startShell(),
columns = INITIAL_COLS,
rows = INITIAL_ROWS,
onChanged = { _revision.value++ },
).also { bridgeRef = it }
bridge.start()
_state.value = TerminalUiState.Connected(bridge)
} catch (e: SshConnectionException) {
_state.value = TerminalUiState.Error(e.message ?: "Verbindungsfehler.")
} catch (e: Exception) {
_state.value = TerminalUiState.Error(e.message ?: e.javaClass.simpleName)
}
}
}
fun send(text: String) {
bridgeRef?.send(text)
}
fun disconnect() {
bridgeRef?.close()
bridgeRef = null
_state.value = TerminalUiState.Closed
}
override fun onCleared() {
disconnect()
super.onCleared()
}
private companion object {
const val INITIAL_COLS = 80
const val INITIAL_ROWS = 24
}
}
sealed interface TerminalUiState {
data object Connecting : TerminalUiState
data class Connected(val bridge: SshTerminalBridge) : TerminalUiState
data class Error(val message: String) : TerminalUiState
data object Closed : TerminalUiState
}

View file

@ -0,0 +1,174 @@
package de.tronax.sshmanager.ui.hosteditor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardOptions
import androidx.hilt.navigation.compose.hiltViewModel
import de.tronax.sshmanager.domain.model.AuthType
import de.tronax.sshmanager.domain.model.NEW_HOST_ID
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HostEditorScreen(
hostId: Long,
onSaved: () -> Unit,
onCancel: () -> Unit,
viewModel: HostEditorViewModel = hiltViewModel(),
) {
val form by viewModel.form.collectAsState()
val keys by viewModel.keys.collectAsState()
val test by viewModel.test.collectAsState()
val isNew = hostId == NEW_HOST_ID
Scaffold(
topBar = {
TopAppBar(title = { Text(if (isNew) "Neuer Host" else "Host bearbeiten") })
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = form.name,
onValueChange = { v -> viewModel.update { it.copy(name = v) } },
label = { Text("Name (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = form.hostName,
onValueChange = { v -> viewModel.update { it.copy(hostName = v) } },
label = { Text("Host / IP") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = form.userName,
onValueChange = { v -> viewModel.update { it.copy(userName = v) } },
label = { Text("Benutzer") },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = form.port.toString(),
onValueChange = { v ->
val p = v.toIntOrNull() ?: form.port
viewModel.update { it.copy(port = p.coerceIn(1, 65535)) }
},
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(0.5f),
)
}
HorizontalDivider()
Text("Authentifizierung", style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = form.authType == AuthType.PASSWORD,
onClick = { viewModel.update { it.copy(authType = AuthType.PASSWORD) } },
label = { Text("Passwort") },
)
FilterChip(
selected = form.authType == AuthType.KEY,
onClick = { viewModel.update { it.copy(authType = AuthType.KEY) } },
label = { Text("Schlüssel") },
)
}
if (form.authType == AuthType.PASSWORD) {
val hint = if (!isNew && form.password.isBlank()) "leer = bestehendes behalten" else ""
OutlinedTextField(
value = form.password,
onValueChange = { v -> viewModel.update { it.copy(password = v) } },
label = { Text("Passwort" + if (hint.isNotEmpty()) " ($hint)" else "") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
} else {
Text("Schlüssel auswählen", style = MaterialTheme.typography.titleSmall)
if (keys.isEmpty()) {
Text(
"Noch keine Schlüssel importiert. Lege zuerst unter „Keys“ einen an.",
style = MaterialTheme.typography.bodySmall,
)
} else {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
keys.forEach { key ->
FilterChip(
selected = form.keyId == key.id,
onClick = { viewModel.update { it.copy(keyId = key.id) } },
label = { Text("${key.name} (${key.keyType})") },
)
}
}
}
}
Spacer(Modifier.height(4.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = { viewModel.testConnection() },
modifier = Modifier.weight(1f),
) { Text("Verbindung testen") }
Button(
onClick = { viewModel.save(onSaved) },
modifier = Modifier.weight(1f),
) { Text("Speichern") }
}
when (val t = test) {
TestResult.Idle -> {}
TestResult.Testing -> Text("Teste Verbindung…", color = MaterialTheme.colorScheme.secondary)
TestResult.Success -> Text("✓ Verbindung erfolgreich.", color = MaterialTheme.colorScheme.primary)
is TestResult.Failure -> Text("${t.message}", color = MaterialTheme.colorScheme.error)
}
OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) {
Text("Abbrechen")
}
}
}
}

View file

@ -0,0 +1,148 @@
package de.tronax.sshmanager.ui.hosteditor
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.tronax.sshmanager.data.repo.Host
import de.tronax.sshmanager.data.repo.HostRepository
import de.tronax.sshmanager.data.repo.SshKey
import de.tronax.sshmanager.data.repo.SshKeyRepository
import de.tronax.sshmanager.domain.model.AuthType
import de.tronax.sshmanager.domain.model.NEW_HOST_ID
import de.tronax.sshmanager.ssh.SshConnectionException
import de.tronax.sshmanager.ssh.SshConnectionManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Editable host fields kept in the UI. */
data class HostForm(
val id: Long = NEW_HOST_ID,
val name: String = "",
val hostName: String = "",
val port: Int = 22,
val userName: String = "",
val authType: AuthType = AuthType.PASSWORD,
val password: String = "",
val keyId: Long? = null,
)
sealed interface TestResult {
data object Idle : TestResult
data object Testing : TestResult
data object Success : TestResult
data class Failure(val message: String) : TestResult
}
@HiltViewModel
class HostEditorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val hosts: HostRepository,
private val keysRepo: SshKeyRepository,
private val connections: SshConnectionManager,
) : ViewModel() {
private val hostId: Long = savedStateHandle["hostId"] ?: NEW_HOST_ID
private val _form = MutableStateFlow(HostForm())
val form: StateFlow<HostForm> = _form.asStateFlow()
private val _test = MutableStateFlow<TestResult>(TestResult.Idle)
val test: StateFlow<TestResult> = _test.asStateFlow()
/** All keys, for the key picker. */
val keys: StateFlow<List<SshKey>> = keysRepo.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
init {
if (hostId != NEW_HOST_ID) {
viewModelScope.launch {
hosts.getById(hostId)?.let { h ->
_form.value = HostForm(
id = h.id,
name = h.name,
hostName = h.hostName,
port = h.port,
userName = h.userName,
authType = h.authType,
password = h.password ?: "",
keyId = h.keyId,
)
}
}
}
}
fun update(transform: (HostForm) -> HostForm) {
_form.value = transform(_form.value)
}
fun save(onDone: () -> Unit) {
val f = _form.value
if (!isValid(f)) return
viewModelScope.launch {
hosts.save(
Host(
id = f.id,
name = f.name,
hostName = f.hostName,
port = f.port,
userName = f.userName,
groupId = null,
authType = f.authType,
// Only persist the password if it was provided/changed; for existing hosts
// with no new password we keep the stored one by passing null on edit.
password = when {
f.authType == AuthType.KEY -> null
f.id != NEW_HOST_ID && f.password.isBlank() -> null
else -> f.password
},
keyId = if (f.authType == AuthType.KEY) f.keyId else null,
lastConnected = null,
),
)
onDone()
}
}
fun testConnection() {
val f = _form.value
if (!isValid(f)) {
_test.value = TestResult.Failure("Bitte alle Pflichtfelder ausfüllen.")
return
}
_test.value = TestResult.Testing
viewModelScope.launch {
val candidate = Host(
id = f.id,
name = f.name,
hostName = f.hostName,
port = f.port,
userName = f.userName,
groupId = null,
authType = f.authType,
password = f.password.takeIf { it.isNotBlank() },
keyId = f.keyId,
lastConnected = null,
)
try {
connections.connect(candidate).use { /* just authenticate + close */ }
_test.value = TestResult.Success
} catch (e: SshConnectionException) {
_test.value = TestResult.Failure(e.message ?: "Verbindungsfehler")
} catch (e: Exception) {
_test.value = TestResult.Failure(e.message ?: e.javaClass.simpleName)
}
}
}
private fun isValid(f: HostForm): Boolean =
f.hostName.isNotBlank() && f.userName.isNotBlank() &&
(f.authType == AuthType.PASSWORD && f.password.isNotBlank() ||
f.authType == AuthType.KEY && f.keyId != null)
}

View file

@ -0,0 +1,110 @@
package de.tronax.sshmanager.ui.hostlist
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import de.tronax.sshmanager.data.repo.Host
import de.tronax.sshmanager.domain.model.AuthType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HostListScreen(
onAddHost: () -> Unit,
onEditHost: (Long) -> Unit,
onOpenTerminal: (Long) -> Unit,
viewModel: HostListViewModel = hiltViewModel(),
) {
val hosts by viewModel.hostsFlow.collectAsState()
Scaffold(
topBar = { TopAppBar(title = { Text("Hosts") }) },
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onAddHost,
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Host hinzufügen") },
)
},
) { padding ->
if (hosts.isEmpty()) {
androidx.compose.foundation.layout.Box(
Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
Text(
"Noch keine Hosts.\nTippe auf „Host hinzufügen“, um eine Verbindung anzulegen.",
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
} else {
LazyColumn(
Modifier
.fillMaxSize()
.padding(padding),
) {
items(hosts, key = { it.id }) { host ->
HostRow(
host = host,
onConnect = { onOpenTerminal(host.id) },
onEdit = { onEditHost(host.id) },
onDelete = { viewModel.delete(host.id) },
)
}
}
}
}
}
@Composable
private fun HostRow(
host: Host,
onConnect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
ListItem(
headlineContent = { Text(host.name.ifBlank { "${host.userName}@${host.hostName}" }) },
supportingContent = {
val auth = if (host.authType == AuthType.KEY) "Schlüssel" else "Passwort"
Text("${host.userName}@${host.hostName}:${host.port} · $auth")
},
trailingContent = {
androidx.compose.foundation.layout.Row {
IconButton(onClick = onEdit) {
Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten")
}
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = "Löschen")
}
}
},
leadingContent = {
IconButton(onClick = onConnect) {
Icon(Icons.AutoMirrored.Filled.Login, contentDescription = "Verbinden")
}
},
)
}

View file

@ -0,0 +1,22 @@
package de.tronax.sshmanager.ui.hostlist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.tronax.sshmanager.data.repo.Host
import de.tronax.sshmanager.data.repo.HostRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class HostListViewModel @Inject constructor(
private val hosts: HostRepository,
) : ViewModel() {
val hostsFlow: StateFlow<List<Host>> = hosts.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun delete(id: Long) = viewModelScope.launch { hosts.delete(id) }
}

View file

@ -0,0 +1,136 @@
package de.tronax.sshmanager.ui.keyimport
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KeyImportScreen(
onDone: () -> Unit,
viewModel: KeyImportViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val form by viewModel.form.collectAsState()
val status by viewModel.status.collectAsState()
// SAF picker: read the picked file as UTF-8 text and hand it to the ViewModel.
val pickFile = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri ->
if (uri != null) {
runCatching {
context.contentResolver.openInputStream(uri)?.use { input ->
val text = input.bufferedReader(Charsets.UTF_8).readText()
val name = uri.lastPathSegment ?: "key"
viewModel.onFileRead(name, text)
}
}
}
}
Scaffold(
topBar = { TopAppBar(title = { Text("Schlüssel importieren") }) },
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"Wähle eine private Schlüsseldatei (z. B. id_rsa, id_ed25519). " +
"Der Schlüssel wird mit dem Android Keystore verschlüsselt gespeichert.",
style = MaterialTheme.typography.bodySmall,
)
OutlinedButton(
onClick = { pickFile.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth(),
) {
Text(form.fileName ?: "Datei auswählen…")
}
OutlinedTextField(
value = form.name,
onValueChange = { v -> viewModel.update { it.copy(name = v) } },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
val needsPassphrase = status is ImportStatus.NeedsPassphrase
if (needsPassphrase) {
HorizontalDivider()
Text(
"Diese Schlüsseldatei scheint passphrase-geschützt zu sein.",
style = MaterialTheme.typography.bodySmall,
)
OutlinedTextField(
value = form.passphrase,
onValueChange = { v -> viewModel.update { it.copy(passphrase = v) } },
label = { Text("Passphrase") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
Text(
"Hinweis: aktuell können passphrase-geschützte Keys geprüft, aber für das " +
"Login musst du sie ohne Passphrase speichern (MVP-Einschränkung).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(4.dp))
Button(
onClick = { viewModel.save(onDone) },
enabled = form.keyText.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Importieren") }
OutlinedButton(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
Text("Abbrechen")
}
when (val s = status) {
ImportStatus.Idle -> {}
ImportStatus.Parsing -> Text("Prüfe Schlüssel…", color = MaterialTheme.colorScheme.secondary)
ImportStatus.Saved -> Text("✓ Importiert.", color = MaterialTheme.colorScheme.primary)
is ImportStatus.NeedsPassphrase -> {}
is ImportStatus.Error -> Text("${s.message}", color = MaterialTheme.colorScheme.error)
}
}
}
}

View file

@ -0,0 +1,89 @@
package de.tronax.sshmanager.ui.keyimport
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.tronax.sshmanager.data.repo.SshKeyRepository
import de.tronax.sshmanager.ssh.SshKeyParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
import javax.inject.Inject
data class ImportForm(
val name: String = "",
val fileName: String? = null,
val keyText: String = "",
val passphrase: String = "",
)
sealed interface ImportStatus {
data object Idle : ImportStatus
data object Parsing : ImportStatus
data object Saved : ImportStatus
data class NeedsPassphrase(val forKey: String) : ImportStatus
data class Error(val message: String) : ImportStatus
}
@HiltViewModel
class KeyImportViewModel @Inject constructor(
private val keys: SshKeyRepository,
private val parser: SshKeyParser, // not @Inject-bound; see provision in SshModule
) : ViewModel() {
private val _form = MutableStateFlow(ImportForm())
val form: StateFlow<ImportForm> = _form.asStateFlow()
private val _status = MutableStateFlow<ImportStatus>(ImportStatus.Idle)
val status: StateFlow<ImportStatus> = _status.asStateFlow()
fun update(transform: (ImportForm) -> ImportForm) {
_form.value = transform(_form.value)
}
/** Called after the user picked a file: store text and probe for passphrase. */
fun onFileRead(fileName: String, text: String) {
_form.value = _form.value.copy(fileName = fileName, keyText = text, name = nameOr(fileName))
_status.value = ImportStatus.Idle
viewModelScope.launch {
_status.value = ImportStatus.Parsing
val needs = withContext(Dispatchers.IO) { parser.needsPassphrase(text) }
_status.value = if (needs) ImportStatus.NeedsPassphrase(fileName) else ImportStatus.Idle
}
}
/** Validate + store. Re-uses a previously entered passphrase if present. */
fun save(onDone: () -> Unit) {
val f = _form.value
if (f.keyText.isBlank()) {
_status.value = ImportStatus.Error("Kein Schlüssel ausgewählt.")
return
}
viewModelScope.launch {
_status.value = ImportStatus.Parsing
val pass = f.passphrase.takeIf { it.isNotEmpty() }?.toCharArray()
try {
val parsed = withContext(Dispatchers.IO) { parser.parse(f.keyText, pass) }
keys.save(
name = f.name.ifBlank { f.fileName ?: "Schlüssel" },
keyType = parsed.keyType,
fingerprint = parsed.fingerprint,
keyPem = f.keyText,
hasPassphrase = parsed.hasPassphrase,
)
_status.value = ImportStatus.Saved
onDone()
} catch (e: IOException) {
_status.value =
ImportStatus.Error(e.message ?: "Schlüssel konnte nicht gelesen werden.")
}
}
}
private fun nameOr(fileName: String): String =
fileName.substringAfterLast('/').substringBeforeLast('.')
}

View file

@ -0,0 +1,102 @@
package de.tronax.sshmanager.ui.keylist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import de.tronax.sshmanager.data.repo.SshKey
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KeyListScreen(
onImportKey: () -> Unit,
onBack: () -> Unit,
viewModel: KeyListViewModel = hiltViewModel(),
) {
val keys by viewModel.keysFlow.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("SSH-Schlüssel") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onImportKey,
icon = { Icon(Icons.Outlined.Key, contentDescription = null) },
text = { Text("Schlüssel importieren") },
)
},
) { padding ->
if (keys.isEmpty()) {
EmptyKeys(
Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
onImport = onImportKey,
)
} else {
LazyColumn(
Modifier
.fillMaxSize()
.padding(padding),
) {
items(keys, key = { it.id }) { key ->
ListItem(
leadingContent = { Icon(Icons.Outlined.Key, contentDescription = null) },
headlineContent = { Text(key.name) },
supportingContent = {
Text("${key.keyType} · ${key.fingerprint.take(24)}")
},
trailingContent = {
IconButton(onClick = { viewModel.delete(key.id) }) {
Icon(Icons.Filled.Delete, contentDescription = "Löschen")
}
},
)
}
}
}
}
}
@Composable
private fun EmptyKeys(modifier: Modifier, onImport: () -> Unit) {
androidx.compose.foundation.layout.Column(
modifier = modifier,
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Noch keine Schlüssel importiert.")
TextButton(onClick = onImport) { Text("Schlüssel importieren") }
}
}

View file

@ -0,0 +1,22 @@
package de.tronax.sshmanager.ui.keylist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.tronax.sshmanager.data.repo.SshKey
import de.tronax.sshmanager.data.repo.SshKeyRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class KeyListViewModel @Inject constructor(
private val keys: SshKeyRepository,
) : ViewModel() {
val keysFlow: StateFlow<List<SshKey>> = keys.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun delete(id: Long) = viewModelScope.launch { keys.delete(id) }
}

View file

@ -0,0 +1,104 @@
package de.tronax.sshmanager.ui.nav
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import de.tronax.sshmanager.ui.hosteditor.HostEditorScreen
import de.tronax.sshmanager.ui.hostlist.HostListScreen
import de.tronax.sshmanager.ui.keyimport.KeyImportScreen
import de.tronax.sshmanager.ui.keylist.KeyListScreen
import de.tronax.sshmanager.ui.settings.SettingsScreen
import de.tronax.sshmanager.terminal.ui.TerminalScreen
@Composable
fun AppNavGraph() {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
// Bottom bar only on the two top-level list screens.
val showBottomBar = currentRoute in TOP_DESTINATIONS.map { it.route }
Scaffold(
bottomBar = {
if (showBottomBar) {
NavigationBar {
TOP_DESTINATIONS.forEach { dest ->
NavigationBarItem(
selected = currentRoute == dest.route,
onClick = {
navController.navigate(dest.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(dest.icon, contentDescription = dest.label) },
label = { Text(dest.label) },
)
}
}
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Routes.HOST_LIST,
modifier = Modifier.padding(innerPadding),
) {
composable(Routes.HOST_LIST) {
HostListScreen(
onAddHost = { navController.navigate(Routes.hostEditor()) },
onEditHost = { id -> navController.navigate(Routes.hostEditor(id)) },
onOpenTerminal = { id -> navController.navigate(Routes.terminal(id)) },
)
}
composable(Routes.KEY_LIST) {
KeyListScreen(
onImportKey = { navController.navigate(Routes.KEY_IMPORT) },
onBack = { navController.navigateUp() },
)
}
composable(Routes.SETTINGS) {
SettingsScreen(onBack = { navController.navigateUp() })
}
composable(
route = Routes.HOST_EDITOR,
arguments = listOf(navArgument("hostId") { type = NavType.LongType }),
) { entry ->
val hostId = entry.arguments?.getLong("hostId") ?: Routes.NEW_HOST_ID
HostEditorScreen(
hostId = hostId,
onSaved = { navController.navigateUp() },
onCancel = { navController.navigateUp() },
)
}
composable(Routes.KEY_IMPORT) {
KeyImportScreen(onDone = { navController.popBackStack(Routes.KEY_LIST, false) })
}
composable(
route = Routes.TERMINAL,
arguments = listOf(navArgument("hostId") { type = NavType.LongType }),
) {
TerminalScreen(
onClose = { navController.popBackStack(Routes.HOST_LIST, false) },
)
}
}
}
}

View file

@ -0,0 +1,37 @@
package de.tronax.sshmanager.ui.nav
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.ui.graphics.vector.ImageVector
/** Central, type-safe-ish route definitions for navigation-compose. */
object Routes {
const val HOST_LIST = "hosts"
const val KEY_LIST = "keys"
const val SETTINGS = "settings"
const val HOST_EDITOR = "host/{hostId}"
const val KEY_IMPORT = "keys/import"
const val TERMINAL = "terminal/{hostId}"
fun hostEditor(hostId: Long? = null) =
if (hostId == null) "host/0" else "host/$hostId"
fun terminal(hostId: Long) = "terminal/$hostId"
/** Edit vs. create is encoded via hostId == 0 meaning "new". */
const val NEW_HOST_ID = 0L
}
data class TopDestination(
val route: String,
val label: String,
val icon: ImageVector,
)
val TOP_DESTINATIONS = listOf(
TopDestination(Routes.HOST_LIST, "Hosts", Icons.Outlined.Storage),
TopDestination(Routes.KEY_LIST, "Keys", Icons.Outlined.Key),
TopDestination(Routes.SETTINGS, "Settings", Icons.Outlined.Settings),
)

View file

@ -0,0 +1,232 @@
package de.tronax.sshmanager.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import de.tronax.sshmanager.data.settings.SettingsDefaults
import de.tronax.sshmanager.data.settings.ThemeMode
/**
* General settings: terminal font size, theme, default port, plus an "About" section
* showing the app version. Every change is persisted immediately via the ViewModel.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val settings by viewModel.settings.collectAsState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState(),
)
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text("Einstellungen") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
scrollBehavior = scrollBehavior,
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SectionHeader("Terminal")
FontSizeRow(
value = settings.terminalFontSize,
onChange = viewModel::setTerminalFontSize,
)
Spacer(Modifier.height(8.dp))
HorizontalDivider()
SectionHeader("Darstellung")
ThemeRow(settings.themeMode, viewModel::setThemeMode)
Spacer(Modifier.height(8.dp))
HorizontalDivider()
SectionHeader("Verbindungen")
DefaultPortRow(
value = settings.defaultPort,
onChange = viewModel::setDefaultPort,
)
Spacer(Modifier.height(8.dp))
HorizontalDivider()
SectionHeader("Über")
AboutSection(version = viewModel.appVersion)
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = androidx.compose.material3.MaterialTheme.typography.labelLarge,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
}
@Composable
private fun FontSizeRow(
value: Int,
onChange: (Int) -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Schriftgröße", style = androidx.compose.material3.MaterialTheme.typography.bodyLarge)
Text(
"$value sp",
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
fontFamily = FontFamily.Monospace,
color = androidx.compose.material3.MaterialTheme.colorScheme.secondary,
)
}
Slider(
value = value.toFloat(),
onValueChange = { onChange(it.toInt()) },
valueRange = SettingsDefaults.TERMINAL_FONT_SIZE_MIN.toFloat()..
SettingsDefaults.TERMINAL_FONT_SIZE_MAX.toFloat(),
steps = SettingsDefaults.TERMINAL_FONT_SIZE_MAX -
SettingsDefaults.TERMINAL_FONT_SIZE_MIN - 1,
)
// Live preview of the chosen terminal font size.
Text(
text = "user@host:~$ ls -la",
fontFamily = FontFamily.Monospace,
fontSize = value.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start,
)
}
}
@Composable
private fun ThemeRow(
current: ThemeMode,
onChange: (ThemeMode) -> Unit,
) {
Text("Theme", style = androidx.compose.material3.MaterialTheme.typography.bodyLarge)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ThemeMode.entries.forEach { mode ->
FilterChip(
selected = current == mode,
onClick = { onChange(mode) },
label = { Text(mode.label) },
)
}
}
}
@Composable
private fun DefaultPortRow(
value: Int,
onChange: (Int) -> Unit,
) {
// Local editable buffer; committed on focus loss / explicit parse to avoid writing
// half-typed numbers to the repository on every keystroke.
var text by remember(value) { mutableStateOf(value.toString()) }
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("Standard-Port", style = androidx.compose.material3.MaterialTheme.typography.bodyLarge)
OutlinedTextField(
value = text,
onValueChange = { input ->
// Allow only digits, keep the field editable while typing.
if (input.all { it.isDigit() }) {
text = input
input.toIntOrNull()?.takeIf { it in 1..65535 }?.let(onChange)
}
},
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun AboutSection(version: String) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("App-Version", style = androidx.compose.material3.MaterialTheme.typography.bodyLarge)
Text(
version,
fontFamily = FontFamily.Monospace,
color = androidx.compose.material3.MaterialTheme.colorScheme.secondary,
)
}
Spacer(Modifier.height(4.dp))
Text(
"Mobile SSH Manager",
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
)
Text(
"SSH-Verbindungen verwalten und per integriertem Terminal nutzen.",
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private val ThemeMode.label: String
get() = when (this) {
ThemeMode.SYSTEM -> "System"
ThemeMode.DARK -> "Dunkel"
ThemeMode.LIGHT -> "Hell"
}

View file

@ -0,0 +1,50 @@
package de.tronax.sshmanager.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.tronax.sshmanager.BuildConfig
import de.tronax.sshmanager.data.settings.AppSettings
import de.tronax.sshmanager.data.settings.SettingsDefaults
import de.tronax.sshmanager.data.settings.SettingsRepository
import de.tronax.sshmanager.data.settings.ThemeMode
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Exposes [AppSettings] as state and forwards mutations back to [SettingsRepository].
* Also exposes the app version (read once from BuildConfig) for the "About" section.
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val repo: SettingsRepository,
) : ViewModel() {
val settings: StateFlow<AppSettings> = repo.settings.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = AppSettings(
terminalFontSize = SettingsDefaults.TERMINAL_FONT_SIZE_DEFAULT,
themeMode = SettingsDefaults.THEME_DEFAULT,
defaultPort = SettingsDefaults.DEFAULT_PORT,
),
)
/** "versionName (versionCode)" — sourced from build.gradle.kts defaultConfig. */
val appVersion: String = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
fun setTerminalFontSize(sp: Int) = viewModelScope.launch {
repo.setTerminalFontSize(sp)
}
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch {
repo.setThemeMode(mode)
}
fun setDefaultPort(port: Int) = viewModelScope.launch {
repo.setDefaultPort(port)
}
}

View file

@ -0,0 +1,50 @@
package de.tronax.sshmanager.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Dark-first palette tuned for a terminal/SSH tool: dark surfaces, green accent.
private val DarkColors = darkColorScheme(
primary = Color(0xFF7CFFB2),
onPrimary = Color(0xFF003821),
primaryContainer = Color(0xFF00522F),
onPrimaryContainer = Color(0xFF98FFCB),
secondary = Color(0xFF7FD3ED),
onSecondary = Color(0xFF003544),
background = Color(0xFF0F1419),
onBackground = Color(0xFFE3E5E7),
surface = Color(0xFF161B22),
onSurface = Color(0xFFE3E5E7),
surfaceVariant = Color(0xFF1F262E),
onSurfaceVariant = Color(0xFFBFC7CF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
)
private val LightColors = lightColorScheme(
primary = Color(0xFF006D3B),
onPrimary = Color(0xFFFFFFFF),
secondary = Color(0xFF006587),
onSecondary = Color(0xFFFFFFFF),
background = Color(0xFFFAFCFB),
onBackground = Color(0xFF1A1C1B),
surface = Color(0xFFFAFCFB),
onSurface = Color(0xFF1A1C1B),
)
@Composable
fun MobileSshManagerTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
// This app is terminal-oriented; we default to dark even in light system theme.
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = MaterialTheme.typography,
content = content,
)
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0F1419"
android:pathData="M0,0h108v108h-108z" />
</vector>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Terminal prompt ">_" centered on the safe inset area. -->
<path
android:fillColor="#7CFFB2"
android:pathData="M38,52 h24 v4 h-24 z" />
<path
android:fillColor="#7CFFB2"
android:pathData="M38,40 h4 v24 h-4 z" />
<path
android:fillColor="#7CFFB2"
android:pathData="M62,40 h4 v16 l10,-8 l0,5 l-14,11 z" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">SSH Manager</string>
</resources>

View file

@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base theme replaced by the Compose theme in code; only the parent matters here. -->
<style name="Theme.MobileSshManager" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Sensitive data (encrypted keys/passwords) lives in the app DB; do not back it up. -->
</full-backup-content>

8
build.gradle.kts Normal file
View file

@ -0,0 +1,8 @@
// Top-level build file. Plugins are declared here and applied in :app via version catalog.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}

12
gradle.properties Normal file
View file

@ -0,0 +1,12 @@
# JVM-Args for the Gradle daemon: use JDK 21 features and enough heap.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+UseParallelGC
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=false
# AndroidX
android.useAndroidX=true
android.nonTransitiveRClass=true
# Kotlin
kotlin.code.style=official

64
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,64 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
ksp = "2.0.21-1.0.28"
coreKtx = "1.13.1"
lifecycle = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.10.01"
navigationCompose = "2.8.4"
hilt = "2.52"
hiltNavigationCompose = "1.2.0"
room = "2.6.1"
coroutines = "1.9.0"
sshj = "0.39.0"
bouncyCastle = "1.78.1"
termuxEmulator = "v0.118.3"
datastore = "1.1.1"
[libraries]
# AndroidX core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
# Compose
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
# SSH + crypto
sshj = { group = "com.hierynomus", name = "sshj", version.ref = "sshj" }
bouncy-castle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncyCastle" }
# Terminal emulation (Termux engine) via JitPack
termux-emulator = { group = "com.github.termux.termux-app", name = "terminal-emulator", version.ref = "termuxEmulator" }
# DataStore (master passphrase flag)
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
gradlew vendored Executable file
View file

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

26
settings.gradle.kts Normal file
View file

@ -0,0 +1,26 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
// Termux terminal-emulator is published here (Maven Central copy is outdated).
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "Mobile SSH Manager"
include(":app")