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:
parent
429ad179ec
commit
bb7662ca63
37 changed files with 332 additions and 184 deletions
143
Audit.md
Normal file
143
Audit.md
Normal 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`.
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)) },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue