Audit: security hardening, bug fixes, dead code cleanup across all modules

vault-crypto: secure_zero for all sensitive buffers (Argon2 core, BLAKE2b,
GCM tag/lengths), JNI NewByteArray null checks, Kotlin input validation.
lib-ssh: SSHSession thread safety (debugLogBuffer sync), EOF check fix,
session reuse guard, stderr restoration in finally block.
lib-terminal-view: italic rendering (faux skew), hidden text (fg=bg),
DECCKM reset fix, reflowResize mode preservation, render thread TOCTOU fix.
lib-terminal-keyboard: dead code removal, kotlin.math migration, touch
allocation elimination in KeyboardPageView.
app: Hilt-injected CredentialStore (was duplicate instance), hardcoded
strings to resources (BiometricAuthManager, VaultImportViewModel),
null safety (!! elimination in 5 files), deprecated API fixes, unused
imports/strings cleanup, test API drift fixes, migration 10→11 test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-03 08:44:45 +02:00
parent 429ad179ec
commit bb7662ca63
37 changed files with 332 additions and 184 deletions

143
Audit.md Normal file
View file

@ -0,0 +1,143 @@
# Audit Log — 2026-04-03
> Comprehensive codebase audit. Each entry: file path, what changed, why.
---
## lib-vault-crypto
### `lib-vault-crypto/src/main/cpp/vault_crypto.cpp`
- **BUG: Missing null check on `NewByteArray`** in all 3 JNI functions (`nativeDeriveKey`, `nativeEncrypt`, `nativeDecrypt`). `NewByteArray()` can return null on OOM; calling `SetByteArrayRegion` on null crashes the JVM. Added null check + secure_zero + cleanup before returning nullptr.
- **SECURITY: Tag buffer not zeroed after encrypt**`tag[16]` stack variable held GCM auth tag but was never zeroed. Added `secure_zero(tag, sizeof(tag))`.
- **SECURITY: Derived key output not zeroed on Argon2 error path**`output[32]` could contain partial key derivation state. Added `secure_zero(output, sizeof(output))`.
- **SECURITY: Encrypt output buffer not zeroed on AES-GCM error path** — heap-allocated `output` freed without zeroing on failure. Added `secure_zero(output, outLen)` before `delete[]`.
### `lib-vault-crypto/src/main/cpp/aes256gcm.c`
- **SECURITY: `lengths` buffer not zeroed**`lengths[16]` (GHASH bit-length encoding) not zeroed in encrypt cleanup or two decrypt cleanup paths. Added `secure_zero(lengths, sizeof(lengths))` in all three places.
### `lib-vault-crypto/src/main/cpp/argon2/core.c`
- **SECURITY: Uses `memset` instead of `secure_zero`**`blockhash` (H0 pre-image with password hash), `final_block`, and `memory` arena zeroed with `memset`, which compiler can optimize away. Replaced with `secure_zero` (volatile function pointer trick).
### `lib-vault-crypto/src/main/cpp/argon2/blake2b.c`
- **SECURITY: Uses `memset` instead of `secure_zero`** — Three `memset` calls zeroing sensitive data in `blake2b_init_key`, `blake2b_final`, `blake2b_long`. Replaced with `secure_zero`.
### `lib-vault-crypto/src/main/java/com/roundingmobile/vaultcrypto/VaultCrypto.kt`
- **INPUT VALIDATION: Missing minimum ciphertext size check** — Native code validated `dataLen < 28` but Kotlin API had no guard. Added `require(ciphertext.size >= 28)` for fail-fast before JNI boundary.
---
## lib-ssh
### `lib-ssh/src/main/java/com/roundingmobile/ssh/SSHSession.kt`
- **BUG: `readLoop` EOF check** — Changed `if (n <= 0)` to `if (n < 0)`. `InputStream.read()` returns -1 for EOF; 0 is not EOF.
- **THREAD SAFETY: `debugLogBuffer.clear()` without synchronization** — Wrapped in `synchronized(debugLogBuffer)` to match the manual sync blocks in `debugLog()`.
- **THREAD SAFETY: `debugLogBuffer.toList()` without synchronization** — Wrapped in `synchronized(debugLogBuffer)` to prevent `ConcurrentModificationException`.
- **ROBUSTNESS: stderr/SLF4J restoration not in `finally`** — Moved `System.setErr(origErr)` and SLF4J property restoration from duplicate try/catch paths into single `finally` block.
- **BUG: Missing guard against reuse of cleaned-up session** — Added `cleanedUp.get()` check at start of `connect()`. After `disconnect()`, scope is cancelled and writeExecutor shut down; reconnecting would silently malfunction.
### `lib-ssh/src/test/java/com/roundingmobile/ssh/SSHConnectionTest.kt`
- **DEAD CODE: 3 unused imports** — Removed `delay`, `flow.first`, `launch`.
---
## lib-terminal-view
### `lib-terminal-view/src/main/java/com/roundingmobile/terminalview/SelectionMagnifier.kt`
- **DEAD CODE: Unused import** — Removed `android.graphics.RectF`.
- **DEAD CODE: Dead fields** — Removed `velocity`, `lastUpdateTime`, `lastCol`, `lastRow` — computed but never read.
### `lib-terminal-view/src/main/java/com/roundingmobile/terminalview/TerminalRenderer.kt`
- **BUG: Missing italic rendering** — SGR 3 (italic) parsed/stored but never rendered. Added `textPaint.textSkewX = if (attr.isItalic) -0.25f else 0f` for faux italic.
- **BUG: Hidden text drawn visibly** — SGR 8 (hidden/concealed) parsed but renderer drew text in foreground color. Added `if (attr.isHidden)` branch setting `textPaint.color = bg`.
- **BUG: Bold+hidden interaction** — Bold-bright color override would execute after hidden-text color, making bold+hidden visible. Restructured so bold-bright is in `else` branch.
### `lib-terminal-view/src/main/java/com/roundingmobile/terminalview/engine/ScreenBuffer.kt`
- **BUG: `reset()` didn't reset `applicationCursorKeys`** — DECCKM not cleared during RIS/DECSTR. Cursor keys would remain in application mode after reset. Added `applicationCursorKeys = false`.
- **BUG: `reflowResize()` didn't preserve `applicationCursorKeys` or `vt52Mode`** — Modes lost on terminal resize (pinch-zoom, rotation). Added both to mode copy block.
### `lib-terminal-view/src/main/java/com/roundingmobile/terminalview/TerminalSurfaceView.kt`
- **RACE CONDITION: Render thread read `screenBuffer` multiple times per frame**`@Volatile` field could change between reads if UI thread sets it to null. Captured into local `val screen` once per frame.
---
## lib-terminal-keyboard
### `lib-terminal-keyboard/src/main/kotlin/com/roundingmobile/keyboard/TerminalKeyboard.kt`
- **DEAD CODE: Redundant import** — Removed `import com.roundingmobile.keyboard.model.KeyEvent` (covered by wildcard).
- **DEAD CODE: Always-true guard** — Removed `if (!isLongPressActive)` guard that was unreachable when true (early return above).
### `lib-terminal-keyboard/src/main/kotlin/com/roundingmobile/keyboard/view/QuickBarView.kt`
- **CODE QUALITY: `Math.abs()` → `kotlin.math.abs()`** — 7 occurrences replaced with idiomatic Kotlin.
### `lib-terminal-keyboard/src/main/kotlin/com/roundingmobile/keyboard/view/KeyboardPageView.kt`
- **PERFORMANCE: `findKeyAt()` allocated `RectF` per key per touch** — Replaced with inline bounds arithmetic. Zero allocations.
---
## app — Data Layer, DI, Auth, Crypto, Terminal
### `app/src/main/java/com/roundingmobile/sshworkbench/auth/BiometricAuthManager.kt`
- **HARDCODED STRINGS: 4 user-visible strings**`"SSH Workbench"`, `"Authenticate to continue"`, `"Confirm Identity"`, `"Cancelled"`. Replaced with `R.string.biometric_prompt_title`, `R.string.biometric_prompt_subtitle`, `R.string.biometric_confirm_identity`, `R.string.biometric_cancelled`. Added new strings to all 3 locale files.
### `app/src/main/java/com/roundingmobile/sshworkbench/terminal/TerminalService.kt`
- **DEAD CODE: 2 unused imports** — Removed `AuthPromptCallback` and `HostKeyVerifyCallback` (moved to `SshConnectionHelper` but imports left behind).
- **BUG: Duplicate CredentialStore instance**`by lazy { CredentialStore(applicationContext) }` created a second instance outside Hilt's singleton. Replaced with `@Inject lateinit var credentialStore`.
### `app/src/main/java/com/roundingmobile/sshworkbench/crypto/KeyGenerator.kt`
- **DEAD CODE: Unused import** — Removed `SubjectPublicKeyInfoFactory`.
### `app/src/main/java/com/roundingmobile/sshworkbench/terminal/TerminalDialogs.kt`
- **DEPRECATED API: `getDrawable()` and `setColorFilter()`** — Replaced with `ContextCompat.getDrawable()` and `DrawableCompat.setTint()`.
---
## app — UI Screens, ViewModels, Navigation
### `app/src/main/java/com/roundingmobile/sshworkbench/ui/theme/Theme.kt`
- **DEAD CODE: 4 unused imports**`android.os.Build`, `isSystemInDarkTheme`, `dynamicDarkColorScheme`, `LocalContext`.
### `app/src/main/java/com/roundingmobile/sshworkbench/ui/MainActivity.kt`
- **DEAD CODE: 2 unused imports**`ViewGroup`, `BuiltInThemes`.
- **DEPRECATED API: `getParcelableExtra<Uri>()`** — Replaced with version-safe call using `Build.VERSION.SDK_INT` check.
- **NULL SAFETY: `activeSessions[sid]!!`** — Changed to null-safe access. Concurrent map update between `in` check and access could crash.
- **NULL SAFETY: `doRefresh!!.invoke()`** — Restructured to use local typed variable.
### `app/src/main/java/com/roundingmobile/sshworkbench/ui/MainViewModel.kt`
- **DEAD CODE: Unused import** — Removed `Snippet`.
### `app/src/main/java/com/roundingmobile/sshworkbench/ui/screens/TerminalPane.kt`
- **DEAD CODE: 2 unused imports**`ViewGroup`, `LinearLayout`.
### `app/src/main/java/com/roundingmobile/sshworkbench/ui/screens/ConnectionItemCard.kt`
- **NULL SAFETY: 2 `!!` on `sessionInfo`** — Changed to smart-cast with combined null check.
### `app/src/main/java/com/roundingmobile/sshworkbench/ui/screens/PortForwardSection.kt`
- **NULL SAFETY: 2 `!!` patterns** — Converted to `?.let { }`.
### `app/src/main/java/com/roundingmobile/sshworkbench/ui/screens/VaultExportSheet.kt`
- **NULL SAFETY: 3 `!!` on `qrBitmap`** — Captured into local val after null check.
### `app/src/main/java/com/roundingmobile/sshworkbench/vault/VaultImportViewModel.kt`
- **HARDCODED STRING: `"Import failed"`** — Replaced with `R.string.vault_import_failed`. Added to all 3 locale files.
---
## app — Tests, String Resources, Build
### `app/src/test/java/com/roundingmobile/sshworkbench/TabTypeAndLabelTest.kt`
- **BUG: SftpTabInfo API drift**`label` field removed, replaced by `createdAt`/`error`. 13 tests referenced removed field. Rewrote all SftpTabInfo tests to match current class shape.
### `app/src/test/java/com/roundingmobile/sshworkbench/ProFeaturesTest.kt`
- **BUG: Missing `quickConnectHistory` member** — Inline `ProFeatures` implementations missing new interface member. Added to all implementations + assertions.
### `app/src/test/java/com/roundingmobile/sshworkbench/PortForwardFreeGateTest.kt`
- **BUG: Missing `quickConnectHistory` member** — Same as above.
### `app/src/androidTest/java/com/roundingmobile/sshworkbench/RoomMigrationTest.kt`
- **COVERAGE GAP: Missing migration 10→11 test** — DB at version 11 but migration tests only covered to 10. Added `migration10to11()` and `fullMigration1to11()`.
### String Resources (EN/ES/SV)
- **DEAD CODE: 8 unused strings removed**`connected_since`, `session_count_one`, `session_count_many`, `state_connecting`, `state_error`, `state_idle`, `key_gen_failed`, `key_manager_auth_required`.
- **CLEANUP: Stale TODO comments removed** — 13 `<!-- TODO: verify translation -->` from ES, 14 from SV.
- **CONSISTENCY: Missing SV strings added**`biometric_confirm_identity`, `biometric_cancelled`.
- **NEW STRINGS: Added to all 3 locales**`biometric_confirm_identity`, `biometric_cancelled`, `vault_import_failed`.

View file

@ -155,21 +155,38 @@ class RoomMigrationTest {
}
@Test
fun fullMigration1to10() {
fun migration10to11() {
var db = helper.createDatabase(dbName, 10)
db.execSQL("INSERT INTO saved_connections (name, host, port, username, authType, lastConnected, nickname, color, startupCommand, themeName, keyId, lastSessionStart, lastSessionEnd, lastSessionDurationMs, protocol, jumpHostId, agentForwarding, wifiLock, keyboardSettings, autoReconnect) VALUES ('test', 'host', 22, 'user', 'password', 0, '', 0, '', '', 0, 0, 0, 0, 'ssh', NULL, 0, 0, '', 1)")
db.close()
db = helper.runMigrationsAndValidate(dbName, 11, true, AppDatabase.MIGRATION_10_11)
// Verify fontSizeSp column with default=0
val cursor = db.query("SELECT fontSizeSp FROM saved_connections")
assertTrue(cursor.moveToFirst())
assertEquals(0f, cursor.getFloat(0), 0.001f)
cursor.close()
db.close()
}
@Test
fun fullMigration1to11() {
var db = helper.createDatabase(dbName, 1)
db.execSQL("INSERT INTO saved_connections (name, host, port, username, authType, lastConnected) VALUES ('test', 'host', 22, 'user', 'password', 0)")
db.close()
db = helper.runMigrationsAndValidate(dbName, 10, true,
db = helper.runMigrationsAndValidate(dbName, 11, true,
AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4,
AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7,
AppDatabase.MIGRATION_7_8, AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10)
AppDatabase.MIGRATION_7_8, AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10,
AppDatabase.MIGRATION_10_11)
// Verify data survived all migrations
val cursor = db.query("SELECT name, host FROM saved_connections")
val cursor = db.query("SELECT name, host, fontSizeSp FROM saved_connections")
assertTrue(cursor.moveToFirst())
assertEquals("test", cursor.getString(0))
assertEquals("host", cursor.getString(1))
assertEquals(0f, cursor.getFloat(2), 0.001f)
cursor.close()
db.close()
}

View file

@ -2,5 +2,5 @@ package com.roundingmobile.sshworkbench
// Auto-generated — do not edit
object BuildTimestamp {
const val TIME = "2026-04-03 00:29:22"
const val TIME = "2026-04-03 08:41:58"
}

View file

@ -4,6 +4,7 @@ import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.roundingmobile.sshworkbench.R
import com.roundingmobile.sshworkbench.data.TerminalPreferences
import kotlinx.coroutines.flow.first
import javax.inject.Inject
@ -91,8 +92,8 @@ class BiometricAuthManager @Inject constructor(
}
showPrompt(
activity = activity,
title = "SSH Workbench",
subtitle = "Authenticate to continue",
title = activity.getString(R.string.biometric_prompt_title),
subtitle = activity.getString(R.string.biometric_prompt_subtitle),
onSuccess = {
onAuthenticated()
onSuccess()
@ -118,7 +119,7 @@ class BiometricAuthManager @Inject constructor(
}
showPrompt(
activity = activity,
title = "Confirm Identity",
title = activity.getString(R.string.biometric_confirm_identity),
subtitle = reason,
onSuccess = onSuccess,
onError = onError
@ -150,7 +151,7 @@ class BiometricAuthManager @Inject constructor(
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
BiometricPrompt.ERROR_CANCELED -> {
onError?.invoke("Cancelled")
onError?.invoke(activity.getString(R.string.biometric_cancelled))
}
else -> {
onError?.invoke(errString.toString())

View file

@ -10,7 +10,6 @@ import org.bouncycastle.crypto.params.RSAKeyGenerationParameters
import org.bouncycastle.crypto.params.RSAKeyParameters
import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters
import org.bouncycastle.crypto.util.PrivateKeyInfoFactory
import org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory
import org.bouncycastle.openssl.jcajce.JcaPEMWriter
import org.bouncycastle.util.io.pem.PemObject
import java.io.StringWriter

View file

@ -8,7 +8,6 @@ import android.text.InputType
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.ScrollView
@ -539,8 +538,8 @@ object TerminalDialogs {
/** Create a tinted drawable for use with MaterialAlertDialogBuilder.setIcon() */
private fun tintedIcon(context: Context, resId: Int, tint: Int): android.graphics.drawable.Drawable {
val drawable = context.resources.getDrawable(resId, null).mutate()
drawable.setColorFilter(tint, android.graphics.PorterDuff.Mode.SRC_IN)
val drawable = androidx.core.content.ContextCompat.getDrawable(context, resId)!!.mutate()
androidx.core.graphics.drawable.DrawableCompat.setTint(drawable, tint)
return drawable
}

View file

@ -16,9 +16,7 @@ import android.net.wifi.WifiManager
import android.os.PowerManager
import android.telephony.TelephonyManager
import com.roundingmobile.ssh.AuthPrompt
import com.roundingmobile.ssh.AuthPromptCallback
import com.roundingmobile.ssh.HostKeyAction
import com.roundingmobile.ssh.HostKeyVerifyCallback
import com.roundingmobile.ssh.SSHAuth
import com.roundingmobile.ssh.SSHConnectionConfig
import com.roundingmobile.ssh.SSHSession
@ -154,12 +152,10 @@ class TerminalService : Service() {
/** Callback for server banners during auth — set by the Activity */
var onBanner: ((String) -> Unit)? = null
/** Credential store for TOFU host key verification */
private val credentialStore by lazy { CredentialStore(applicationContext) }
/** Hilt-injected DAOs — shared singleton with the rest of the app */
/** Hilt-injected DAOs + credential store — shared singleton with the rest of the app */
@javax.inject.Inject lateinit var savedConnectionDao: com.roundingmobile.sshworkbench.data.local.SavedConnectionDao
@javax.inject.Inject lateinit var portForwardDao: com.roundingmobile.sshworkbench.data.local.PortForwardDao
@javax.inject.Inject lateinit var credentialStore: CredentialStore
/**
* Host key verification callback set by the Activity.

View file

@ -11,7 +11,6 @@ import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import android.widget.Toast
@ -54,7 +53,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.roundingmobile.keyboard.TerminalKeyboard
import com.roundingmobile.keyboard.model.toBytes
import com.roundingmobile.keyboard.theme.BuiltInThemes
import com.roundingmobile.sshworkbench.BuildConfig
import com.roundingmobile.sshworkbench.R
import com.roundingmobile.sshworkbench.auth.BiometricAuthManager
@ -394,9 +392,9 @@ class MainActivity : AppCompatActivity() {
)
}
}
} else if (sid in activeSessions) {
val state = activeSessions[sid]!!
TerminalPane(
} else {
val state = activeSessions[sid]
if (state != null) TerminalPane(
sessionId = sid,
terminalService = mainViewModel.terminalService,
visible = isVisible,
@ -622,7 +620,7 @@ class MainActivity : AppCompatActivity() {
},
refreshSnippets = { callback ->
// Store refresh as a reusable function
doRefresh = {
val refresh: () -> Unit = {
lifecycleScope.launch(Dispatchers.IO) {
kotlinx.coroutines.delay(100)
val updated = mainViewModel.snippetDao.getAllOnce()
@ -630,7 +628,8 @@ class MainActivity : AppCompatActivity() {
launch(Dispatchers.Main) { callback(updated) }
}
}
doRefresh!!.invoke()
doRefresh = refresh
refresh()
}
)
}
@ -690,7 +689,12 @@ class MainActivity : AppCompatActivity() {
private fun handleSharedImage(intent: Intent?) {
if (intent?.action != Intent.ACTION_SEND) return
val imageUri = intent.getParcelableExtra<android.net.Uri>(Intent.EXTRA_STREAM) ?: return
val imageUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, android.net.Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
} ?: return
lifecycleScope.launch(Dispatchers.IO) {
try {

View file

@ -8,7 +8,6 @@ import com.roundingmobile.sshworkbench.data.TerminalPreferences
import com.roundingmobile.sshworkbench.data.local.SavedConnection
import com.roundingmobile.sshworkbench.data.local.SavedConnectionDao
import com.roundingmobile.sshworkbench.data.local.SnippetDao
import com.roundingmobile.sshworkbench.data.local.Snippet
import com.roundingmobile.sshworkbench.R
import com.roundingmobile.sshworkbench.di.StringResolver
import com.roundingmobile.sshworkbench.pro.ProFeatures

View file

@ -118,7 +118,7 @@ internal fun ConnectionItem(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
if (isActive && sessionTrackingEnabled) {
if (isActive && sessionTrackingEnabled && sessionInfo != null) {
// Live timer
val context = LocalContext.current
val is24h = android.text.format.DateFormat.is24HourFormat(context)
@ -130,7 +130,7 @@ internal fun ConnectionItem(
}
}
// Terminal sessions line
if (sessionInfo!!.terminalCount > 0) {
if (sessionInfo.terminalCount > 0) {
SessionTypeLine(
count = sessionInfo.terminalCount,
color = ActiveGreen,
@ -150,9 +150,9 @@ internal fun ConnectionItem(
is24h = is24h
)
}
} else if (isActive) {
} else if (isActive && sessionInfo != null) {
// Session tracking disabled — just show count per type
if (sessionInfo!!.terminalCount > 0) {
if (sessionInfo.terminalCount > 0) {
SessionTypeDot(sessionInfo.terminalCount, ActiveGreen)
}
if (sessionInfo.sftpCount > 0) {

View file

@ -119,8 +119,7 @@ fun PortForwardSection(
}
// Long-press context menu
if (longPressedPortForward != null) {
val pfToAction = longPressedPortForward!!
longPressedPortForward?.let { pfToAction ->
AlertDialog(
onDismissRequest = { longPressedPortForward = null },
title = {
@ -153,8 +152,7 @@ fun PortForwardSection(
}
// Delete confirmation dialog
if (confirmDeletePortForward != null) {
val pfToDelete = confirmDeletePortForward!!
confirmDeletePortForward?.let { pfToDelete ->
AlertDialog(
onDismissRequest = { confirmDeletePortForward = null },
title = { Text(stringResource(R.string.port_forward_delete_title)) },

View file

@ -1,9 +1,7 @@
package com.roundingmobile.sshworkbench.ui.screens
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.LinearLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable

View file

@ -245,9 +245,10 @@ fun VaultExportScreen(
CircularProgressIndicator()
}
} else if (qrBitmap != null) {
val bitmap = qrBitmap ?: return@Column
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Image(
bitmap = qrBitmap!!.asImageBitmap(),
bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(R.string.vault_qr_content_desc),
modifier = Modifier.size(200.dp)
)
@ -259,7 +260,7 @@ fun VaultExportScreen(
) {
OutlinedButton(
onClick = {
saveQrToGallery(context, qrBitmap!!)
saveQrToGallery(context, bitmap)
viewModel.onQrSavedOrShared()
},
modifier = Modifier.weight(1f)
@ -270,7 +271,7 @@ fun VaultExportScreen(
}
OutlinedButton(
onClick = {
shareQr(context, qrBitmap!!)
shareQr(context, bitmap)
viewModel.onQrSavedOrShared()
},
modifier = Modifier.weight(1f)

View file

@ -1,13 +1,9 @@
package com.roundingmobile.sshworkbench.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val TerminalDarkBg = Color(0xFF1A1A2E)
private val TerminalSurface = Color(0xFF16213E)

View file

@ -348,7 +348,7 @@ class VaultImportViewModel @Inject constructor(
_importSummary.value = "$imported|$skipped"
_importState.value = ImportState.SUCCESS
} catch (e: Exception) {
_errorMessage.value = e.message ?: "Import failed"
_errorMessage.value = e.message ?: strings.getString(R.string.vault_import_failed)
_importState.value = ImportState.ERROR
}
}

View file

@ -34,15 +34,9 @@
<string name="ssh_keys">Claves SSH</string>
<string name="new_connection">Nueva conexión</string>
<string name="state_connected">Conectado</string>
<string name="state_connecting">Conectando\u2026</string>
<string name="state_disconnected">Desconectado</string>
<string name="state_error">Error</string>
<string name="state_idle">Inactivo</string>
<string name="session_id_label">Sesión %d</string>
<string name="session_state_opened">%1$s \u2022 abierta %2$s</string>
<string name="session_count_one">1 sesión</string>
<string name="session_count_many">%d sesiones</string>
<string name="connected_since">Conectado desde %1$s \u00b7 %2$s</string>
<string name="last_session_start">Última sesión: %s</string>
<string name="last_session_range">Última sesión: %1$s \u2013 %2$s \u00b7 %3$s</string>
<string name="relative_just_now">ahora mismo</string>
@ -121,6 +115,8 @@
<string name="biometric_prompt_subtitle">Autenticación necesaria</string>
<string name="biometric_unlock">Desbloquear</string>
<string name="biometric_auth_failed">Autenticación fallida</string>
<string name="biometric_confirm_identity">Confirmar identidad</string>
<string name="biometric_cancelled">Cancelado</string>
<!-- Terminal Activity -->
<string name="reconnecting_in">Reconectando en %ds…</string>
@ -189,8 +185,8 @@
<string name="vault_qr_content_desc">Código QR</string>
<string name="vault_unknown_mode">Modo de vault desconocido</string>
<string name="vault_export_failed">Error en la exportación</string>
<string name="vault_import_failed">Error en la importación</string>
<string name="vault_qr_decode_failed">Error en el descifrado QR</string>
<string name="key_gen_failed">Error al generar la clave: %s</string>
<!-- Snippets -->
<string name="snippets">Fragmentos</string>
@ -254,23 +250,8 @@
<string name="qb_pos_hidden">Oculta</string>
<!-- Port Forwarding -->
<string name="port_forwarding">Reenvío de puertos</string> <!-- TODO: verify translation -->
<string name="port_forward_local">Local</string> <!-- TODO: verify translation -->
<string name="port_forward_remote">Remoto</string> <!-- TODO: verify translation -->
<string name="port_forward_dynamic">Dinámico</string> <!-- TODO: verify translation -->
<string name="port_forward_add">Agregar reenvío de puerto</string> <!-- TODO: verify translation -->
<string name="port_forward_edit">Editar reenvío de puerto</string> <!-- TODO: verify translation -->
<string name="port_forward_label_hint">ej. Túnel MySQL</string> <!-- TODO: verify translation -->
<string name="port_forward_bind_address">Dirección de enlace</string> <!-- TODO: verify translation -->
<string name="port_forward_local_port">Puerto local</string> <!-- TODO: verify translation -->
<string name="port_forward_remote_host">Host remoto</string> <!-- TODO: verify translation -->
<string name="port_forward_remote_host_hint">ej. db.local o 10.0.0.5</string> <!-- TODO: verify translation -->
<string name="port_forward_remote_port">Puerto remoto</string> <!-- TODO: verify translation -->
<string name="port_forward_empty">Sin reenvíos de puerto — toca + para agregar uno</string> <!-- TODO: verify translation -->
<string name="port_forward_socks5_hint">Proxy SOCKS5 — las apps se conectan aquí para tunelizar todo el tráfico a través de SSH</string>
<string name="port_forward_warn_bind_all">Será accesible desde todos los dispositivos en tu red</string> <!-- TODO: verify translation -->
<string name="port_forward_error_port_low">Android no permite a las apps enlazar puertos por debajo de 1024</string> <!-- TODO: verify translation -->
<string name="port_forward_delete_title">¿Eliminar reenvío de puerto?</string>
<string name="port_forwarding">Reenvío de puertos</string> <string name="port_forward_local">Local</string> <string name="port_forward_remote">Remoto</string> <string name="port_forward_dynamic">Dinámico</string> <string name="port_forward_add">Agregar reenvío de puerto</string> <string name="port_forward_edit">Editar reenvío de puerto</string> <string name="port_forward_label_hint">ej. Túnel MySQL</string> <string name="port_forward_bind_address">Dirección de enlace</string> <string name="port_forward_local_port">Puerto local</string> <string name="port_forward_remote_host">Host remoto</string> <string name="port_forward_remote_host_hint">ej. db.local o 10.0.0.5</string> <string name="port_forward_remote_port">Puerto remoto</string> <string name="port_forward_empty">Sin reenvíos de puerto — toca + para agregar uno</string> <string name="port_forward_socks5_hint">Proxy SOCKS5 — las apps se conectan aquí para tunelizar todo el tráfico a través de SSH</string>
<string name="port_forward_warn_bind_all">Será accesible desde todos los dispositivos en tu red</string> <string name="port_forward_error_port_low">Android no permite a las apps enlazar puertos por debajo de 1024</string> <string name="port_forward_delete_title">¿Eliminar reenvío de puerto?</string>
<string name="port_forward_failed_port_in_use">Reenvío de puerto fallido: el puerto local %d ya está en uso</string>
<string name="port_forward_failed_detail">Reenvío de puerto %1$s %2$s:%3$d fallido: %4$s</string>
@ -363,7 +344,6 @@
<string name="key_manager_read_error">Error al leer el archivo de clave: %s</string>
<string name="key_manager_export_reason">Exportar clave privada</string>
<string name="key_manager_private_copied">Clave privada copiada</string>
<string name="key_manager_auth_required">Se requiere autenticación del dispositivo para exportar claves privadas</string>
<string name="key_manager_import_title">Importar clave SSH</string>
<string name="key_manager_key_name">Nombre de la clave</string>
<string name="key_manager_import_button">Importar</string>

View file

@ -34,15 +34,9 @@
<string name="ssh_keys">SSH-nycklar</string>
<string name="new_connection">Ny anslutning</string>
<string name="state_connected">Ansluten</string>
<string name="state_connecting">Ansluter\u2026</string>
<string name="state_disconnected">Frånkopplad</string>
<string name="state_error">Fel</string>
<string name="state_idle">Inaktiv</string>
<string name="session_id_label">Session %d</string>
<string name="session_state_opened">%1$s \u2022 öppnad %2$s</string>
<string name="session_count_one">1 session</string>
<string name="session_count_many">%d sessioner</string>
<string name="connected_since">Ansluten sedan %1$s \u00b7 %2$s</string>
<string name="last_session_start">Senaste session: %s</string>
<string name="last_session_range">Senaste session: %1$s \u2013 %2$s \u00b7 %3$s</string>
<string name="relative_just_now">just nu</string>
@ -121,6 +115,8 @@
<string name="biometric_prompt_subtitle">Autentisera för att fortsätta</string>
<string name="biometric_unlock">Lås upp</string>
<string name="biometric_auth_failed">Autentisering misslyckades</string>
<string name="biometric_confirm_identity">Bekräfta identitet</string>
<string name="biometric_cancelled">Avbruten</string>
<!-- Terminal Activity -->
<string name="reconnecting_in">Återansluter om %ds…</string>
@ -189,8 +185,8 @@
<string name="vault_qr_content_desc">QR-kod</string>
<string name="vault_unknown_mode">Okänt vault-läge</string>
<string name="vault_export_failed">Export misslyckades</string>
<string name="vault_import_failed">Import misslyckades</string>
<string name="vault_qr_decode_failed">QR-dekryptering misslyckades</string>
<string name="key_gen_failed">Nyckelgenerering misslyckades: %s</string>
<!-- Snippets -->
<string name="snippets">Kodsnuttar</string>
@ -211,8 +207,7 @@
<!-- Pro Features -->
<string name="biometric_lock_feature">Biometriskt lås</string>
<string name="agent_forwarding_feature">Agentvidarebefordran</string> <!-- TODO: verify translation -->
<string name="agent_forwarding_feature">Agentvidarebefordran</string>
<!-- Keyboard Settings Dialog -->
<string name="hide_keyboard">Dölj tangentbord</string>
<string name="keyboard_settings_title">Tangentbordsinställningar</string>
@ -254,23 +249,8 @@
<string name="qb_pos_hidden">Dold</string>
<!-- Port Forwarding -->
<string name="port_forwarding">Portvidarebefordran</string> <!-- TODO: verify translation -->
<string name="port_forward_local">Lokal</string> <!-- TODO: verify translation -->
<string name="port_forward_remote">Fjärr</string> <!-- TODO: verify translation -->
<string name="port_forward_dynamic">Dynamisk</string> <!-- TODO: verify translation -->
<string name="port_forward_add">Lägg till portvidarebefordran</string> <!-- TODO: verify translation -->
<string name="port_forward_edit">Redigera portvidarebefordran</string> <!-- TODO: verify translation -->
<string name="port_forward_label_hint">t.ex. MySQL-tunnel</string> <!-- TODO: verify translation -->
<string name="port_forward_bind_address">Bindningsadress</string> <!-- TODO: verify translation -->
<string name="port_forward_local_port">Lokal port</string> <!-- TODO: verify translation -->
<string name="port_forward_remote_host">Fjärrvärd</string> <!-- TODO: verify translation -->
<string name="port_forward_remote_host_hint">t.ex. db.local eller 10.0.0.5</string> <!-- TODO: verify translation -->
<string name="port_forward_remote_port">Fjärrport</string> <!-- TODO: verify translation -->
<string name="port_forward_empty">Inga portvidarebefordringar — tryck + för att lägga till en</string> <!-- TODO: verify translation -->
<string name="port_forward_socks5_hint">SOCKS5-proxy — appar ansluter hit för att tunnla all trafik genom SSH</string>
<string name="port_forward_warn_bind_all">Detta blir tillgängligt från alla enheter i ditt nätverk</string> <!-- TODO: verify translation -->
<string name="port_forward_error_port_low">Android tillåter inte appar att binda till portar under 1024</string> <!-- TODO: verify translation -->
<string name="port_forward_delete_title">Ta bort portvidarebefordran?</string>
<string name="port_forwarding">Portvidarebefordran</string> <string name="port_forward_local">Lokal</string> <string name="port_forward_remote">Fjärr</string> <string name="port_forward_dynamic">Dynamisk</string> <string name="port_forward_add">Lägg till portvidarebefordran</string> <string name="port_forward_edit">Redigera portvidarebefordran</string> <string name="port_forward_label_hint">t.ex. MySQL-tunnel</string> <string name="port_forward_bind_address">Bindningsadress</string> <string name="port_forward_local_port">Lokal port</string> <string name="port_forward_remote_host">Fjärrvärd</string> <string name="port_forward_remote_host_hint">t.ex. db.local eller 10.0.0.5</string> <string name="port_forward_remote_port">Fjärrport</string> <string name="port_forward_empty">Inga portvidarebefordringar — tryck + för att lägga till en</string> <string name="port_forward_socks5_hint">SOCKS5-proxy — appar ansluter hit för att tunnla all trafik genom SSH</string>
<string name="port_forward_warn_bind_all">Detta blir tillgängligt från alla enheter i ditt nätverk</string> <string name="port_forward_error_port_low">Android tillåter inte appar att binda till portar under 1024</string> <string name="port_forward_delete_title">Ta bort portvidarebefordran?</string>
<string name="port_forward_failed_port_in_use">Portvidarebefordran misslyckades: lokal port %d används redan</string>
<string name="port_forward_failed_detail">Portvidarebefordran %1$s %2$s:%3$d misslyckades: %4$s</string>
@ -363,7 +343,6 @@
<string name="key_manager_read_error">Kunde inte läsa nyckelfilen: %s</string>
<string name="key_manager_export_reason">Exportera privat nyckel</string>
<string name="key_manager_private_copied">Privat nyckel kopierad</string>
<string name="key_manager_auth_required">Enhetsautentisering krävs för att exportera privata nycklar</string>
<string name="key_manager_import_title">Importera SSH-nyckel</string>
<string name="key_manager_key_name">Nyckelnamn</string>
<string name="key_manager_import_button">Importera</string>

View file

@ -33,15 +33,9 @@
<string name="ssh_keys">SSH Keys</string>
<string name="new_connection">New Connection</string>
<string name="state_connected">Connected</string>
<string name="state_connecting">Connecting\u2026</string>
<string name="state_disconnected">Disconnected</string>
<string name="state_error">Error</string>
<string name="state_idle">Idle</string>
<string name="session_id_label">Session %d</string>
<string name="session_state_opened">%1$s \u2022 opened %2$s</string>
<string name="session_count_one">1 session</string>
<string name="session_count_many">%d sessions</string>
<string name="connected_since">Connected since %1$s \u00b7 %2$s</string>
<string name="last_session_start">Last session: %s</string>
<string name="last_session_range">Last session: %1$s \u2013 %2$s \u00b7 %3$s</string>
<string name="relative_just_now">just now</string>
@ -122,6 +116,8 @@
<string name="biometric_prompt_subtitle">Authenticate to continue</string>
<string name="biometric_unlock">Unlock</string>
<string name="biometric_auth_failed">Authentication failed</string>
<string name="biometric_confirm_identity">Confirm Identity</string>
<string name="biometric_cancelled">Cancelled</string>
<!-- Terminal Activity -->
<string name="reconnecting_in">Reconnecting in %ds…</string>
@ -190,8 +186,8 @@
<string name="vault_qr_content_desc">QR Code</string>
<string name="vault_unknown_mode">Unknown vault mode</string>
<string name="vault_export_failed">Export failed</string>
<string name="vault_import_failed">Import failed</string>
<string name="vault_qr_decode_failed">QR decryption failed</string>
<string name="key_gen_failed">Key generation failed: %s</string>
<!-- Snippets -->
<string name="snippets">Snippets</string>
@ -368,7 +364,6 @@
<string name="key_manager_read_error">Failed to read key file: %s</string>
<string name="key_manager_export_reason">Export private key</string>
<string name="key_manager_private_copied">Private key copied</string>
<string name="key_manager_auth_required">Device authentication required to export private keys</string>
<string name="key_manager_import_title">Import SSH Key</string>
<string name="key_manager_key_name">Key Name</string>
<string name="key_manager_import_button">Import</string>

View file

@ -20,6 +20,7 @@ class PortForwardFreeGateTest {
override fun perConnectionSnippets() = false
override fun maxJumpHops() = 1
override val agentForwarding = false
override val quickConnectHistory = false
override fun showUpgradePrompt(context: Context, featureName: String) {}
}

View file

@ -24,6 +24,7 @@ class ProFeaturesTest {
override fun perConnectionSnippets() = false
override fun maxJumpHops() = 1
override val agentForwarding = false
override val quickConnectHistory = false
override fun showUpgradePrompt(context: Context, featureName: String) {}
}
@ -40,6 +41,7 @@ class ProFeaturesTest {
override fun perConnectionSnippets() = true
override fun maxJumpHops() = Int.MAX_VALUE
override val agentForwarding = true
override val quickConnectHistory = true
override fun showUpgradePrompt(context: Context, featureName: String) {}
}
@ -73,6 +75,7 @@ class ProFeaturesTest {
assertFalse(freeFeatures.saveBuffer)
assertFalse(freeFeatures.mouseSupport)
assertFalse(freeFeatures.agentForwarding)
assertFalse(freeFeatures.quickConnectHistory)
assertFalse(freeFeatures.perConnectionSnippets())
}
@ -86,6 +89,7 @@ class ProFeaturesTest {
assertTrue(proFeatures.saveBuffer)
assertTrue(proFeatures.mouseSupport)
assertTrue(proFeatures.agentForwarding)
assertTrue(proFeatures.quickConnectHistory)
assertTrue(proFeatures.perConnectionSnippets())
}

View file

@ -47,53 +47,60 @@ class TabTypeAndLabelTest {
}
@Test
fun `SftpTabInfo default label is empty`() {
fun `SftpTabInfo default error is null`() {
val info = SftpTabInfo(tabId = 1L, connectionId = 100L)
assertEquals("", info.label)
assertNull(info.error)
}
@Test
fun `SftpTabInfo explicit values override defaults`() {
val ts = 1000L
val info = SftpTabInfo(
tabId = 5L,
connectionId = 42L,
sftpSessionId = "sess-abc",
label = "my-server SFTP"
createdAt = ts,
error = "timeout"
)
assertEquals(5L, info.tabId)
assertEquals(42L, info.connectionId)
assertEquals("sess-abc", info.sftpSessionId)
assertEquals("my-server SFTP", info.label)
assertEquals(ts, info.createdAt)
assertEquals("timeout", info.error)
}
// ---- SftpTabInfo copy ----
@Test
fun `SftpTabInfo copy preserves unmodified fields`() {
val original = SftpTabInfo(tabId = 1L, connectionId = 100L, sftpSessionId = "s1", label = "host")
val copied = original.copy(label = "host (2)")
val ts = 1000L
val original = SftpTabInfo(tabId = 1L, connectionId = 100L, sftpSessionId = "s1", createdAt = ts)
val copied = original.copy(error = "fail")
assertEquals(1L, copied.tabId)
assertEquals(100L, copied.connectionId)
assertEquals("s1", copied.sftpSessionId)
assertEquals("host (2)", copied.label)
assertEquals(ts, copied.createdAt)
assertEquals("fail", copied.error)
}
@Test
fun `SftpTabInfo copy with sftpSessionId preserves other fields`() {
val original = SftpTabInfo(tabId = 7L, connectionId = 50L, label = "web-server")
val ts = 2000L
val original = SftpTabInfo(tabId = 7L, connectionId = 50L, createdAt = ts)
val copied = original.copy(sftpSessionId = "new-session")
assertEquals(7L, copied.tabId)
assertEquals(50L, copied.connectionId)
assertEquals("new-session", copied.sftpSessionId)
assertEquals("web-server", copied.label)
assertEquals(ts, copied.createdAt)
}
// ---- SftpTabInfo equals / hashCode ----
@Test
fun `SftpTabInfo equals for identical values`() {
val a = SftpTabInfo(tabId = 1L, connectionId = 2L, sftpSessionId = "x", label = "lbl")
val b = SftpTabInfo(tabId = 1L, connectionId = 2L, sftpSessionId = "x", label = "lbl")
val ts = 3000L
val a = SftpTabInfo(tabId = 1L, connectionId = 2L, sftpSessionId = "x", createdAt = ts)
val b = SftpTabInfo(tabId = 1L, connectionId = 2L, sftpSessionId = "x", createdAt = ts)
assertEquals(a, b)
}
@ -112,22 +119,24 @@ class TabTypeAndLabelTest {
}
@Test
fun `SftpTabInfo not equal when label differs`() {
val a = SftpTabInfo(tabId = 1L, connectionId = 2L, label = "alpha")
val b = SftpTabInfo(tabId = 1L, connectionId = 2L, label = "beta")
fun `SftpTabInfo not equal when error differs`() {
val a = SftpTabInfo(tabId = 1L, connectionId = 2L, error = "alpha")
val b = SftpTabInfo(tabId = 1L, connectionId = 2L, error = "beta")
assertNotEquals(a, b)
}
@Test
fun `SftpTabInfo hashCode equal for equal objects`() {
val a = SftpTabInfo(tabId = 3L, connectionId = 4L, sftpSessionId = "s", label = "lbl")
val b = SftpTabInfo(tabId = 3L, connectionId = 4L, sftpSessionId = "s", label = "lbl")
val ts = 4000L
val a = SftpTabInfo(tabId = 3L, connectionId = 4L, sftpSessionId = "s", createdAt = ts)
val b = SftpTabInfo(tabId = 3L, connectionId = 4L, sftpSessionId = "s", createdAt = ts)
assertEquals(a.hashCode(), b.hashCode())
}
@Test
fun `SftpTabInfo works correctly in sets and maps`() {
val info = SftpTabInfo(tabId = 1L, connectionId = 2L, label = "test")
val ts = 5000L
val info = SftpTabInfo(tabId = 1L, connectionId = 2L, createdAt = ts)
val infoCopy = info.copy()
val set = setOf(info, infoCopy)
assertEquals(1, set.size)

View file

@ -27,7 +27,7 @@
## Deferred from 2026-04-02 Audit
- **DECCKM for hardware keyboard** — hardware keyboard arrow keys should respect DECCKM (application cursor mode). Currently only the custom keyboard sends application-mode sequences.
- **Italic rendering** — terminal italic text attribute (`SGR 3`) is parsed and stored but not rendered with an italic typeface. Requires loading an italic font variant.
- ~~**Italic rendering**~~ — Done. Faux italic via `textPaint.textSkewX = -0.25f`. Hidden text (SGR 8) also fixed to render fg=bg.
- **Clipboard timed clear** — auto-clear clipboard N seconds after copy for security. Currently clipboard contents persist indefinitely.
- **Vault crypto unit tests**`lib-vault-crypto` JNI (Argon2id + AES-256-GCM) has no unit tests. Requires a test harness that loads the native library.

View file

@ -3,7 +3,7 @@
> Package: `com.roundingmobile.sshworkbench`
> Developer: Rounding Mobile Technologies S.L.
> Min SDK: 27 (Android 8.1) | Target SDK: 36 (Android 16)
> Last updated: 2026-04-02
> Last updated: 2026-04-03
---
@ -333,7 +333,7 @@ All `@HiltViewModel` with `viewModelScope`. ViewModels that need localized strin
### Terminal Session Management
#### `TerminalService` (Foreground Service)
Owns all active sessions. Survives Activity backgrounding. Multiple simultaneous sessions keyed by `sessionId`. Uses `@AndroidEntryPoint` with Hilt-injected DAOs (SavedConnectionDao, PortForwardDao).
Owns all active sessions. Survives Activity backgrounding. Multiple simultaneous sessions keyed by `sessionId`. Uses `@AndroidEntryPoint` with Hilt-injected DAOs (SavedConnectionDao, PortForwardDao) and CredentialStore.
**Extracted managers** (delegated from TerminalService):
- `SftpSessionManager` — Standalone SFTP session lifecycle. Owns `sftpSessions`, `sshSessions`, and `jumpSessions` maps (each SFTP tab has its own SSH connection + optional jump chain). `closeAll()` called in `onDestroy()`
@ -660,7 +660,7 @@ SurfaceView with dedicated render thread.
- Tap → open/copy dialog
#### `TerminalRenderer`
Stateless Canvas drawing. Handles: character grid, cursor (block/underline/bar), selection highlight, URL underlines, colors (16 ANSI + 256-index + 24-bit RGB), bold/italic/underline/strikethrough, reverse video, dim, double-height/double-width.
Stateless Canvas drawing. Handles: character grid, cursor (block/underline/bar), selection highlight, URL underlines, colors (16 ANSI + 256-index + 24-bit RGB), bold/italic (faux via textSkewX)/underline/strikethrough, reverse video, dim, hidden (fg=bg), double-height/double-width.
#### `TerminalTheme`
Terminal color scheme: fg, bg, cursor, 16 ANSI colors. Built-in: Default Dark, Dracula, Monokai, Nord, Solarized Dark/Light, Gruvbox.

View file

@ -138,6 +138,10 @@ class SSHSession {
log("connect: already connected/connecting, ignoring")
return
}
if (cleanedUp.get()) {
log("connect: session already cleaned up, cannot reuse — create a new SSHSession")
return
}
_state.value = SessionState.Connecting(config.host, config.port)
}
log("connect: ${config.username}@${config.host}:${config.port}")
@ -295,25 +299,26 @@ class SSHSession {
monitorConnection()
}
}
// Connection succeeded — discard debug buffer, restore stderr
System.setErr(origErr)
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn")
debugLogBuffer.clear()
// Connection succeeded — discard debug buffer
synchronized(debugLogBuffer) { debugLogBuffer.clear() }
} catch (e: Exception) {
// Connection failed — capture debug log, restore stderr
System.setErr(origErr)
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn")
// Parse captured SSHJ output into debug log
// Connection failed — capture SSHJ trace output into debug log
val captured = captureStream.toString(Charsets.UTF_8.name())
for (line in captured.lines()) {
val trimmed = line.trim()
if (trimmed.isNotEmpty()) debugLog(trimmed)
}
debugLog("ERROR: ${e.javaClass.simpleName}: ${e.message}")
lastFailureDebugLog = debugLogBuffer.toList()
log("connect: failed: ${e.message} (${debugLogBuffer.size} debug lines captured)")
synchronized(debugLogBuffer) {
lastFailureDebugLog = debugLogBuffer.toList()
log("connect: failed: ${e.message} (${debugLogBuffer.size} debug lines captured)")
}
_state.value = SessionState.Error(e.message ?: "Connection failed", e)
cleanup()
} finally {
// Always restore stderr and SLF4J level, even on unexpected throwables
System.setErr(origErr)
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn")
}
}
@ -471,7 +476,7 @@ class SSHSession {
try {
while (true) {
val n = input.read(buf)
if (n <= 0) {
if (n < 0) {
log("readLoop: EOF after $total bytes")
reachedEof = true
break

View file

@ -1,10 +1,6 @@
package com.roundingmobile.ssh
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Assert.*
import org.junit.Test

View file

@ -17,7 +17,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import com.roundingmobile.keyboard.model.KeyEvent
import com.roundingmobile.keyboard.parser.LanguageParser
import com.roundingmobile.keyboard.parser.LayoutParser
import com.roundingmobile.keyboard.theme.BuiltInThemes
@ -360,9 +359,7 @@ class TerminalKeyboard private constructor(
val key = pageView.findKeyAt(event.getX(pIdx), event.getY(pIdx))
pageView.setKeyPressed(pId, null)
hintPopup?.hide()
if (!isLongPressActive) {
keyboardView?.setSwipeEnabled(true)
}
keyboardView?.setSwipeEnabled(true)
// Only fire on UP for non-repeatable keys
// Skip if mod long-press already fired (caps lock was engaged)

View file

@ -196,8 +196,9 @@ class KeyboardPageView(
fun findKeyAt(x: Float, y: Float): KeyDefinition? {
val slop = dpToPx(4f)
return allKeys.firstOrNull { key ->
key.bounds.contains(x, y) ||
RectF(key.bounds).apply { inset(-slop, -slop) }.contains(x, y)
val b = key.bounds
x >= b.left - slop && x <= b.right + slop &&
y >= b.top - slop && y <= b.bottom + slop
}
}

View file

@ -255,9 +255,9 @@ class QuickBarView(context: Context) : View(context) {
val dt = 0.016f // ~60fps
scrollOffset += flingVelocity * dt
val sign = if (flingVelocity > 0) 1f else -1f
val decel = flingFriction * Math.abs(flingVelocity) * dt
val decel = flingFriction * kotlin.math.abs(flingVelocity) * dt
flingVelocity -= decel * sign
if (Math.abs(flingVelocity) < 10f) flingVelocity = 0f
if (kotlin.math.abs(flingVelocity) < 10f) flingVelocity = 0f
}
// Normalize scroll offset
@ -308,9 +308,9 @@ class QuickBarView(context: Context) : View(context) {
val dt = 0.016f
scrollOffset += flingVelocity * dt
val sign = if (flingVelocity > 0) 1f else -1f
val decel = flingFriction * Math.abs(flingVelocity) * dt
val decel = flingFriction * kotlin.math.abs(flingVelocity) * dt
flingVelocity -= decel * sign
if (Math.abs(flingVelocity) < 10f) flingVelocity = 0f
if (kotlin.math.abs(flingVelocity) < 10f) flingVelocity = 0f
}
// Normalize scroll offset
@ -426,7 +426,7 @@ class QuickBarView(context: Context) : View(context) {
velocityTracker?.addMovement(event)
if (isVertical) {
val dy = event.y - scrollTouchY
if (!isScrolling && Math.abs(event.y - scrollTouchStartY) > dpToPx(8f)) {
if (!isScrolling && kotlin.math.abs(event.y - scrollTouchStartY) > dpToPx(8f)) {
isScrolling = true
pressedKeys.clear()
onKeyUp?.invoke() // stop key repeat
@ -440,7 +440,7 @@ class QuickBarView(context: Context) : View(context) {
}
} else {
val dx = event.x - scrollTouchX
if (!isScrolling && Math.abs(event.x - scrollTouchStartX) > dpToPx(8f)) {
if (!isScrolling && kotlin.math.abs(event.x - scrollTouchStartX) > dpToPx(8f)) {
isScrolling = true
pressedKeys.clear()
onKeyUp?.invoke() // stop key repeat
@ -464,7 +464,7 @@ class QuickBarView(context: Context) : View(context) {
velocityTracker?.addMovement(event)
velocityTracker?.computeCurrentVelocity(1000)
val vel = if (isVertical) velocityTracker?.yVelocity ?: 0f else velocityTracker?.xVelocity ?: 0f
if (Math.abs(vel) > 200f) {
if (kotlin.math.abs(vel) > 200f) {
flingVelocity = -vel * 0.3f
postInvalidateOnAnimation()
}

View file

@ -3,7 +3,6 @@ package com.roundingmobile.terminalview
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import com.roundingmobile.terminalview.engine.ScreenBuffer
/**
@ -23,10 +22,6 @@ class SelectionMagnifier {
private set
@Volatile var touchY = 0f
private set
private var lastUpdateTime = 0L
private var lastCol = -1
private var lastRow = -1
private var velocity = 0f
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#E0202030")
@ -47,20 +42,10 @@ class SelectionMagnifier {
}
fun update(absRow: Int, col: Int, x: Float, y: Float) {
val now = System.currentTimeMillis()
val dt = (now - lastUpdateTime).coerceAtLeast(1)
val moved = kotlin.math.abs(col - lastCol) + kotlin.math.abs(absRow - lastRow)
val instantVelocity = moved.toFloat() / dt * 1000f
// Smooth velocity to avoid flickering (weighted average)
velocity = velocity * 0.7f + instantVelocity * 0.3f
this.row = absRow
this.col = col
this.touchX = x
this.touchY = y
this.lastCol = col
this.lastRow = absRow
this.lastUpdateTime = now
this.active = true
}

View file

@ -199,10 +199,15 @@ class TerminalRenderer {
val isUrl = urlMatches?.any { it.row == row && bufCol in it.colStart..it.colEnd } == true
if (ch != ' ' && ch != '\u0000') {
textPaint.color = fg
textPaint.isFakeBoldText = attr.isBold
if (attr.isBold && attr.fgMode == TextAttr.COLOR_MODE_16 && attr.fg < 8) {
textPaint.color = colorPalette[(attr.fg + 8).coerceAtMost(15)]
textPaint.textSkewX = if (attr.isItalic) -0.25f else 0f
if (attr.isHidden) {
textPaint.color = bg
} else {
textPaint.color = fg
if (attr.isBold && attr.fgMode == TextAttr.COLOR_MODE_16 && attr.fg < 8) {
textPaint.color = colorPalette[(attr.fg + 8).coerceAtMost(15)]
}
}
canvas.drawText(ch.toString(), x, y + charBaseline, textPaint)
if (attr.isUnderlined || isUrl) {

View file

@ -884,15 +884,18 @@ class TerminalSurfaceView @JvmOverloads constructor(
try {
canvas = surfaceHolder.lockCanvas()
if (canvas != null) {
// Capture screenBuffer once to avoid TOCTOU race with the UI thread
val screen = screenBuffer
// Consume dirty AFTER lockCanvas succeeds — if lockCanvas returns null,
// the dirty flag is preserved so the next iteration still triggers a render.
screenBuffer?.consumeDirty()
screen?.consumeDirty()
// Detect URLs in the visible viewport for underline rendering
val urls = screenBuffer?.let { urlDetector.detectVisible(it) }
val urls = screen?.let { urlDetector.detectVisible(it) }
renderer.render(
canvas = canvas,
screen = screenBuffer,
screen = screen,
scrollXCols = scrollXCols,
pinchScale = gestureHandler.pinchScale,
isPinching = gestureHandler.isPinching,
@ -909,8 +912,8 @@ class TerminalSurfaceView @JvmOverloads constructor(
drawScrollbar(canvas)
drawJumpButtons(canvas)
drawNewOutputIndicator(canvas)
drawFloatingToolbar(canvas, screenBuffer)
drawMagnifier(canvas, screenBuffer)
drawFloatingToolbar(canvas, screen)
drawMagnifier(canvas, screen)
drawDimensionOverlay(canvas)
} else {
// lockCanvas failed — re-request so we retry on the next iteration

View file

@ -833,6 +833,7 @@ class ScreenBuffer(
originMode = false
reverseScreenMode = false
bracketedPasteMode = false
applicationCursorKeys = false
vt52Mode = false
mouseMode = MouseMode.NONE
mouseEncoding = MouseEncoding.X10
@ -868,6 +869,8 @@ class ScreenBuffer(
newBuf.originMode = originMode
newBuf.reverseScreenMode = reverseScreenMode
newBuf.bracketedPasteMode = bracketedPasteMode
newBuf.applicationCursorKeys = applicationCursorKeys
newBuf.vt52Mode = vt52Mode
newBuf.mouseMode = mouseMode
newBuf.mouseEncoding = mouseEncoding
newBuf.cursorVisible = cursorVisible

View file

@ -266,6 +266,7 @@ int aes256gcm_encrypt(const uint8_t key[AES256GCM_KEY_SIZE],
secure_zero(enc_j0, sizeof(enc_j0));
secure_zero(J0, sizeof(J0));
secure_zero(ICB, sizeof(ICB));
secure_zero(lengths, sizeof(lengths));
return 0;
}
@ -320,6 +321,7 @@ int aes256gcm_decrypt(const uint8_t key[AES256GCM_KEY_SIZE],
secure_zero(enc_j0, sizeof(enc_j0));
secure_zero(computed_tag, sizeof(computed_tag));
secure_zero(J0, sizeof(J0));
secure_zero(lengths, sizeof(lengths));
return -1; /* authentication failed */
}
@ -336,5 +338,6 @@ int aes256gcm_decrypt(const uint8_t key[AES256GCM_KEY_SIZE],
secure_zero(computed_tag, sizeof(computed_tag));
secure_zero(J0, sizeof(J0));
secure_zero(ICB, sizeof(ICB));
secure_zero(lengths, sizeof(lengths));
return 0;
}

View file

@ -5,6 +5,11 @@
#include "blake2.h"
#include <string.h>
/* Secure zeroing that survives compiler dead-store elimination.
volatile function pointer prevents the optimizer from removing the call. */
static void *(*const volatile secure_zero_ptr)(void *, int, size_t) = memset;
#define secure_zero(ptr, len) secure_zero_ptr((ptr), 0, (len))
static const uint64_t blake2b_IV[8] = {
0x6a09e667f3bcc908ULL, 0xbb67ae8584caa73bULL,
0x3c6ef372fe94f82bULL, 0xa54ff53a5f1d36f1ULL,
@ -114,7 +119,7 @@ int blake2b_init_key(blake2b_state *S, size_t outlen, const void *key, size_t ke
memset(block, 0, BLAKE2B_BLOCKBYTES);
memcpy(block, key, keylen);
blake2b_update(S, block, BLAKE2B_BLOCKBYTES);
memset(block, 0, BLAKE2B_BLOCKBYTES);
secure_zero(block, BLAKE2B_BLOCKBYTES);
return 0;
}
@ -161,7 +166,7 @@ int blake2b_final(blake2b_state *S, void *out, size_t outlen) {
for (int i = 0; i < 8; ++i)
store64(buffer + sizeof(uint64_t) * i, S->h[i]);
memcpy(out, buffer, S->outlen);
memset(buffer, 0, sizeof(buffer));
secure_zero(buffer, sizeof(buffer));
return 0;
}
@ -223,6 +228,6 @@ void blake2b_long(void *out, size_t outlen, const void *in, size_t inlen) {
blake2b(hash, remaining, prev, BLAKE2B_OUTBYTES, NULL, 0);
memcpy(pout, hash, remaining);
}
memset(hash, 0, sizeof(hash));
secure_zero(hash, sizeof(hash));
}
}

View file

@ -5,6 +5,11 @@
#include <stdlib.h>
#include <string.h>
/* Secure zeroing that survives compiler dead-store elimination.
volatile function pointer prevents the optimizer from removing the call. */
static void *(*const volatile secure_zero_ptr)(void *, int, size_t) = memset;
#define secure_zero(ptr, len) secure_zero_ptr((ptr), 0, (len))
static inline void store32_le(uint8_t *dst, uint32_t w) {
dst[0] = (uint8_t)(w);
dst[1] = (uint8_t)(w >> 8);
@ -121,7 +126,7 @@ int argon2id_hash(const uint8_t *pwd, uint32_t pwdlen,
blake2b_long((uint8_t *)memory[l * inst.lane_length + 1].v,
ARGON2_BLOCK_SIZE, blockhash, BLAKE2B_OUTBYTES + 8);
}
memset(blockhash, 0, sizeof(blockhash));
secure_zero(blockhash, sizeof(blockhash));
/* Fill memory, pass by pass */
for (uint32_t p = 0; p < params->t_cost; p++) {
@ -150,8 +155,8 @@ int argon2id_hash(const uint8_t *pwd, uint32_t pwdlen,
blake2b_long(out, params->outlen,
(uint8_t *)final_block.v, ARGON2_BLOCK_SIZE);
memset(&final_block, 0, sizeof(final_block));
memset(memory, 0, (size_t)memory_blocks * sizeof(block));
secure_zero(&final_block, sizeof(final_block));
secure_zero(memory, (size_t)memory_blocks * sizeof(block));
free(memory);
return ARGON2_OK;

View file

@ -95,10 +95,16 @@ Java_com_roundingmobile_vaultcrypto_VaultCrypto_nativeDeriveKey(
if (result != ARGON2_OK) {
LOGE("Argon2id failed: %d", result);
secure_zero(output, sizeof(output));
return nullptr;
}
jbyteArray out = env->NewByteArray(ARGON2_OUTPUT_LEN);
if (!out) {
LOGE("Failed to allocate output array");
secure_zero(output, sizeof(output));
return nullptr;
}
env->SetByteArrayRegion(out, 0, ARGON2_OUTPUT_LEN, (jbyte *)output);
secure_zero(output, sizeof(output));
return out;
@ -164,18 +170,26 @@ Java_com_roundingmobile_vaultcrypto_VaultCrypto_nativeEncrypt(
memcpy(output + AES256GCM_NONCE_SIZE + ptLen, tag, AES256GCM_TAG_SIZE);
secure_zero(tag, sizeof(tag));
secure_zero(nonce, sizeof(nonce));
secure_zero(keyPtr, keyLen);
env->ReleaseByteArrayElements(plaintext, ptPtr, JNI_ABORT);
env->ReleaseByteArrayElements(key, keyPtr, JNI_ABORT);
if (result != 0) {
secure_zero(output, outLen);
delete[] output;
LOGE("AES-GCM encrypt failed");
return nullptr;
}
jbyteArray out = env->NewByteArray((jsize)outLen);
if (!out) {
LOGE("Failed to allocate output array");
secure_zero(output, outLen);
delete[] output;
return nullptr;
}
env->SetByteArrayRegion(out, 0, (jsize)outLen, (jbyte *)output);
secure_zero(output, outLen);
delete[] output;
@ -241,6 +255,12 @@ Java_com_roundingmobile_vaultcrypto_VaultCrypto_nativeDecrypt(
}
jbyteArray out = env->NewByteArray((jsize)ctLen);
if (!out) {
LOGE("Failed to allocate output array");
secure_zero(pt, ctLen);
delete[] pt;
return nullptr;
}
env->SetByteArrayRegion(out, 0, (jsize)ctLen, (jbyte *)pt);
secure_zero(pt, ctLen);
delete[] pt;

View file

@ -38,6 +38,7 @@ object VaultCrypto {
*/
fun decrypt(ciphertext: ByteArray, key: ByteArray): ByteArray? {
require(key.size == 32) { "Key must be 32 bytes" }
require(ciphertext.size >= 28) { "Ciphertext too short (need nonce + tag = 28 bytes minimum)" }
return nativeDecrypt(ciphertext, key)
}