commit 66417cc19479a26195623bec37e0256e305fd4fb Author: Tronax Date: Mon Aug 3 18:04:29 2026 +0200 feat: initialize Android SSH manager app with terminal and crypto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6220253 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.zcode/plans/plan-sess_2e35df48-e85a-43a2-9d74-a11c445163f4.md b/.zcode/plans/plan-sess_2e35df48-e85a-43a2-9d74-a11c445163f4.md new file mode 100644 index 0000000..aae7488 --- /dev/null +++ b/.zcode/plans/plan-sess_2e35df48-e85a-43a2-9d74-a11c445163f4.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0967d8b --- /dev/null +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..bfd7d97 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..533fe80 --- /dev/null +++ b/app/proguard-rules.pro @@ -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.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..40679db --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/termux/terminal/TerminalBufferAccess.kt b/app/src/main/java/com/termux/terminal/TerminalBufferAccess.kt new file mode 100644 index 0000000..628fc9d --- /dev/null +++ b/app/src/main/java/com/termux/terminal/TerminalBufferAccess.kt @@ -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 = 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) +} diff --git a/app/src/main/java/de/tronax/sshmanager/MainActivity.kt b/app/src/main/java/de/tronax/sshmanager/MainActivity.kt new file mode 100644 index 0000000..fb3cee9 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/MainActivity.kt @@ -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() + } + } + } + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/SshManagerApp.kt b/app/src/main/java/de/tronax/sshmanager/SshManagerApp.kt new file mode 100644 index 0000000..7d1d28f --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/SshManagerApp.kt @@ -0,0 +1,7 @@ +package de.tronax.sshmanager + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class SshManagerApp : Application() diff --git a/app/src/main/java/de/tronax/sshmanager/core/crypto/KeystoreCrypto.kt b/app/src/main/java/de/tronax/sshmanager/core/crypto/KeystoreCrypto.kt new file mode 100644 index 0000000..07b88e2 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/core/crypto/KeystoreCrypto.kt @@ -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 + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/data/db/AppDatabase.kt b/app/src/main/java/de/tronax/sshmanager/data/db/AppDatabase.kt new file mode 100644 index 0000000..022e171 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/data/db/AppDatabase.kt @@ -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 +} diff --git a/app/src/main/java/de/tronax/sshmanager/data/db/dao/HostDao.kt b/app/src/main/java/de/tronax/sshmanager/data/db/dao/HostDao.kt new file mode 100644 index 0000000..abd782d --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/data/db/dao/HostDao.kt @@ -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> + + @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) +} diff --git a/app/src/main/java/de/tronax/sshmanager/data/db/dao/SshKeyDao.kt b/app/src/main/java/de/tronax/sshmanager/data/db/dao/SshKeyDao.kt new file mode 100644 index 0000000..b8803a1 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/data/db/dao/SshKeyDao.kt @@ -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> + + @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 +} diff --git a/app/src/main/java/de/tronax/sshmanager/data/db/entity/Entities.kt b/app/src/main/java/de/tronax/sshmanager/data/db/entity/Entities.kt new file mode 100644 index 0000000..11b707f --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/data/db/entity/Entities.kt @@ -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, +) diff --git a/app/src/main/java/de/tronax/sshmanager/data/di/DataModule.kt b/app/src/main/java/de/tronax/sshmanager/data/di/DataModule.kt new file mode 100644 index 0000000..7e3a11c --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/data/di/DataModule.kt @@ -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() +} diff --git a/app/src/main/java/de/tronax/sshmanager/data/repo/HostRepository.kt b/app/src/main/java/de/tronax/sshmanager/data/repo/HostRepository.kt new file mode 100644 index 0000000..afc5d38 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/data/repo/HostRepository.kt @@ -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> = + 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, + ) +} diff --git a/app/src/main/java/de/tronax/sshmanager/data/repo/SshKeyRepository.kt b/app/src/main/java/de/tronax/sshmanager/data/repo/SshKeyRepository.kt new file mode 100644 index 0000000..a2bc34f --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/data/repo/SshKeyRepository.kt @@ -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> = + 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, + ) +} diff --git a/app/src/main/java/de/tronax/sshmanager/data/settings/SettingsRepository.kt b/app/src/main/java/de/tronax/sshmanager/data/settings/SettingsRepository.kt new file mode 100644 index 0000000..f11e146 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/data/settings/SettingsRepository.kt @@ -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 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 = 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 } + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/domain/model/Enums.kt b/app/src/main/java/de/tronax/sshmanager/domain/model/Enums.kt new file mode 100644 index 0000000..8b23a32 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/domain/model/Enums.kt @@ -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 diff --git a/app/src/main/java/de/tronax/sshmanager/ssh/SshConnectionManager.kt b/app/src/main/java/de/tronax/sshmanager/ssh/SshConnectionManager.kt new file mode 100644 index 0000000..c6f63de --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ssh/SshConnectionManager.kt @@ -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 + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/ssh/SshKeyParser.kt b/app/src/main/java/de/tronax/sshmanager/ssh/SshKeyParser.kt new file mode 100644 index 0000000..abc8bd5 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ssh/SshKeyParser.kt @@ -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 " + + "(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 + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/ssh/SshSupport.kt b/app/src/main/java/de/tronax/sshmanager/ssh/SshSupport.kt new file mode 100644 index 0000000..ee4ef23 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ssh/SshSupport.kt @@ -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 +} diff --git a/app/src/main/java/de/tronax/sshmanager/ssh/di/SshModule.kt b/app/src/main/java/de/tronax/sshmanager/ssh/di/SshModule.kt new file mode 100644 index 0000000..f6603fa --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ssh/di/SshModule.kt @@ -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() +} diff --git a/app/src/main/java/de/tronax/sshmanager/terminal/KeyEncoder.kt b/app/src/main/java/de/tronax/sshmanager/terminal/KeyEncoder.kt new file mode 100644 index 0000000..3c5361d --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/terminal/KeyEncoder.kt @@ -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-: 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 +} diff --git a/app/src/main/java/de/tronax/sshmanager/terminal/SshTerminalBridge.kt b/app/src/main/java/de/tronax/sshmanager/terminal/SshTerminalBridge.kt new file mode 100644 index 0000000..232ea6e --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/terminal/SshTerminalBridge.kt @@ -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() + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/terminal/TerminalTextStyle.kt b/app/src/main/java/de/tronax/sshmanager/terminal/TerminalTextStyle.kt new file mode 100644 index 0000000..26a235e --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/terminal/TerminalTextStyle.kt @@ -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) + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/terminal/ui/TerminalCanvas.kt b/app/src/main/java/de/tronax/sshmanager/terminal/ui/TerminalCanvas.kt new file mode 100644 index 0000000..ee357c2 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/terminal/ui/TerminalCanvas.kt @@ -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) diff --git a/app/src/main/java/de/tronax/sshmanager/terminal/ui/TerminalScreen.kt b/app/src/main/java/de/tronax/sshmanager/terminal/ui/TerminalScreen.kt new file mode 100644 index 0000000..aea3eec --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/terminal/ui/TerminalScreen.kt @@ -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") } + } + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/terminal/ui/TerminalViewModel.kt b/app/src/main/java/de/tronax/sshmanager/terminal/ui/TerminalViewModel.kt new file mode 100644 index 0000000..f49fc81 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/terminal/ui/TerminalViewModel.kt @@ -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.Connecting) + val state: StateFlow = _state.asStateFlow() + + // Bumped each time the bridge signals a screen change; the Canvas observes it. + private val _revision = MutableStateFlow(0L) + val revision: StateFlow = _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 = 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 +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/hosteditor/HostEditorScreen.kt b/app/src/main/java/de/tronax/sshmanager/ui/hosteditor/HostEditorScreen.kt new file mode 100644 index 0000000..fb4114d --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/hosteditor/HostEditorScreen.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/hosteditor/HostEditorViewModel.kt b/app/src/main/java/de/tronax/sshmanager/ui/hosteditor/HostEditorViewModel.kt new file mode 100644 index 0000000..84a3c6b --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/hosteditor/HostEditorViewModel.kt @@ -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 = _form.asStateFlow() + + private val _test = MutableStateFlow(TestResult.Idle) + val test: StateFlow = _test.asStateFlow() + + /** All keys, for the key picker. */ + val keys: StateFlow> = 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) +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/hostlist/HostListScreen.kt b/app/src/main/java/de/tronax/sshmanager/ui/hostlist/HostListScreen.kt new file mode 100644 index 0000000..9ea43e7 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/hostlist/HostListScreen.kt @@ -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") + } + }, + ) +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/hostlist/HostListViewModel.kt b/app/src/main/java/de/tronax/sshmanager/ui/hostlist/HostListViewModel.kt new file mode 100644 index 0000000..82b1291 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/hostlist/HostListViewModel.kt @@ -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> = hosts.observeAll() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun delete(id: Long) = viewModelScope.launch { hosts.delete(id) } +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/keyimport/KeyImportScreen.kt b/app/src/main/java/de/tronax/sshmanager/ui/keyimport/KeyImportScreen.kt new file mode 100644 index 0000000..dd14f08 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/keyimport/KeyImportScreen.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/keyimport/KeyImportViewModel.kt b/app/src/main/java/de/tronax/sshmanager/ui/keyimport/KeyImportViewModel.kt new file mode 100644 index 0000000..2571814 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/keyimport/KeyImportViewModel.kt @@ -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 = _form.asStateFlow() + + private val _status = MutableStateFlow(ImportStatus.Idle) + val status: StateFlow = _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('.') +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/keylist/KeyListScreen.kt b/app/src/main/java/de/tronax/sshmanager/ui/keylist/KeyListScreen.kt new file mode 100644 index 0000000..c218254 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/keylist/KeyListScreen.kt @@ -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") } + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/keylist/KeyListViewModel.kt b/app/src/main/java/de/tronax/sshmanager/ui/keylist/KeyListViewModel.kt new file mode 100644 index 0000000..38f1390 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/keylist/KeyListViewModel.kt @@ -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> = keys.observeAll() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun delete(id: Long) = viewModelScope.launch { keys.delete(id) } +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/nav/AppNavGraph.kt b/app/src/main/java/de/tronax/sshmanager/ui/nav/AppNavGraph.kt new file mode 100644 index 0000000..c103b03 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/nav/AppNavGraph.kt @@ -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) }, + ) + } + } + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/nav/Routes.kt b/app/src/main/java/de/tronax/sshmanager/ui/nav/Routes.kt new file mode 100644 index 0000000..9073414 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/nav/Routes.kt @@ -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), +) diff --git a/app/src/main/java/de/tronax/sshmanager/ui/settings/SettingsScreen.kt b/app/src/main/java/de/tronax/sshmanager/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..bfcca01 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/settings/SettingsScreen.kt @@ -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" + } diff --git a/app/src/main/java/de/tronax/sshmanager/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/tronax/sshmanager/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..f12402b --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/settings/SettingsViewModel.kt @@ -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 = 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) + } +} diff --git a/app/src/main/java/de/tronax/sshmanager/ui/theme/Theme.kt b/app/src/main/java/de/tronax/sshmanager/ui/theme/Theme.kt new file mode 100644 index 0000000..78689d6 --- /dev/null +++ b/app/src/main/java/de/tronax/sshmanager/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..844e81c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..da51133 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..4e675b3 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + SSH Manager + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4b61680 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..b083e3d --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..72c3c78 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..ec3a01c --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..8404c78 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..42bfeb9 --- /dev/null +++ b/settings.gradle.kts @@ -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")