VP2 rotation fix, NumBlok mini toggle, HW keyboard auto-hide, audit

ViewPager2 rotation phantom (Google Issue 175796502):
- KeyboardView.onSizeChanged rebuilds buildPages() on any width
  transition including initial 0→actual layout, not just on resize.
  The first buildPages() from setup() runs at width=0 (view still
  detached), VP2's internal RecyclerView can't reliably re-anchor to
  the real dimensions that arrive later, leaving a fractional scroll
  offset that leaks the neighbor page on the right edge. A second
  buildPages() once the real size is known produces a clean result.
- KeyboardView.lastModifierStates caches CTRL/ALT/SHIFT state so it
  survives the rebuild.
- Works around an unfixed VP2 1.1.0 bug for non-FragmentStateAdapter
  usage. Fresh launches and all rotation paths confirmed clean on S23.

NumBlok toggle on mini numpad:
- KeyDefinition gains numLabel/numAction optional fields
- KeyAction gains ToggleNumBlock data object
- LayoutParser understands "numLabel"/"numAction" JSON and the
  "toggle_numblock" action type
- KeyboardPageView.numBlockActive swaps label rendering; toggle key
  gets LOCKED-style amber glow while active
- TerminalKeyboard.numBlockActive state; handleKeyAction swaps
  action→numAction; attachMiniTo carries state across rotation rebuilds
- Mini last row: Num 0 \ (base) → Num Ins ~ (numblock)
- Rows 1-3 map to PC-keypad nav: Home/↑/PgUp, ←/Esc/→, End/↓/PgDn
- QuickBarCustomizer serializer/deserializer learned toggle_numblock

Hardware keyboard auto-hide:
- MainActivity derives hasHwKeyboard from LocalConfiguration.current.
  keyboard != KEYBOARD_NOKEYS && hardKeyboardHidden != HARDKEYBOARDHIDDEN_YES
- LaunchedEffect(hasHwKeyboard) { ckbHidden = hasHwKeyboard } seeds
  the toggle on every HW kb connect/disconnect
- In HW kb mode the kebab Show/Hide controls both CKB and QuickBar as
  a pair: val qbShownByUser = if (hasHwKeyboard) !ckbHidden else
  quickBarVisible
- Normal mode unchanged (QB follows its own quickBarVisible pref)

Number row dropdown:
- KeyboardSettingsDialog now shows all four options (top/left/right/
  hidden) in both orientations. Previously left/right were filtered
  out in portrait, preventing the user from setting the mini mode
  without first rotating into landscape.
- Portrait-override on the effective numberRowMode stays in place
  (mini is landscape-only by design — documented in CLAUDE.md and
  auto-memory).

Audit fixes:
- KeyboardPageView: removed key.hint!! by capturing into a local
  hintText val so smart-cast flows through the render branches
- SSHKeyLoader.loadEd25519FromPkcs8: require(seed.size == 32) so a
  malformed PKCS#8 blob fails fast with a clear message instead of
  crashing deep inside EdDSAPrivateKeySpec
- vault_crypto.cpp nativeEncrypt: secure_zero(ptPtr, ptLen) before
  ReleaseByteArrayElements on all three paths (success + two error
  paths), matching the existing key-handling pattern for plaintext
  defence-in-depth through the JNI boundary

Docs:
- TECHNICAL.md: NumBlok, VP2 rebuild workaround, HW keyboard auto-hide,
  portrait mini override notes
- CLAUDE.md: lib-terminal-keyboard module description updated with
  the same invariants
- TODO.md: recently-completed items through 2026-04-06
- Audit.md: investigation log including false positives for future
  reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-06 13:23:13 +02:00
parent f6f0e5e078
commit e243b8e1e5
19 changed files with 278 additions and 56 deletions

64
Audit.md Normal file
View file

@ -0,0 +1,64 @@
# Audit — 2026-04-06
Comprehensive audit across all modules after the NumBlok / HW keyboard / VP2 rotation work in this session. Focus: security, bugs, dead code, file size, code quality. Debug infrastructure (TRACE logs, DevConfig receiver, SessionRecorder) is intentionally preserved and gated on the `dev` flavor.
Parallel scan by module (lib-terminal-keyboard, lib-ssh, lib-terminal-view, lib-vault-crypto, app). Many reported findings were false positives on re-inspection — logged below for clarity.
## Fixes applied
### `lib-terminal-keyboard/src/main/kotlin/com/roundingmobile/keyboard/view/KeyboardPageView.kt`
- **Removed `key.hint!!` on lines 201/208.** Captured `key.hint` into a local `hintText` val at the top of the render branches so Kotlin's smart-cast flows through the `if (hintText != null && hasBelowHint)` / `if (hintText != null && hasCornerHint)` checks. Drop-in safer replacement for the two `!!` assertions — no runtime behavior change.
### `lib-ssh/src/main/java/com/roundingmobile/ssh/SSHKeyLoader.kt`
- **Added `require(seed.size == 32)` in `loadEd25519FromPkcs8`.** A malformed PKCS#8 blob with a non-32-byte OCTET STRING would previously crash inside `EdDSAPrivateKeySpec(seed, …)` with a cryptic stack. Now it fails fast with a clear message: `"Ed25519 seed must be exactly 32 bytes, got $size"`.
### `lib-vault-crypto/src/main/cpp/vault_crypto.cpp`
- **Zero plaintext JNI buffer before `ReleaseByteArrayElements` in `nativeEncrypt`.** The existing code was already zeroing the key buffer (`secure_zero(keyPtr, keyLen)`) before release with `JNI_ABORT`, but leaking the plaintext buffer through the same path. Added three `secure_zero(ptPtr, ptLen)` calls — one on the normal success path, two on error paths (urandom failure, keyPtr acquisition failure). This matches the key-handling pattern and closes a defence-in-depth gap for sensitive plaintext passing through encrypt. On Android ART, `GetByteArrayElements` returns a JVM-managed copy, so zeroing does not affect the caller's input array.
## Findings that were NOT real issues (documented to avoid re-flagging)
### `lib-terminal-keyboard/TerminalKeyboard.kt`
- "Comment syntax error `/ NumBlock…` on lines 173/814" — **false positive**. Both lines use valid `//` comments; the agent misread the indentation.
- "`_rawJson!!` on line 1086" — **false positive**. Guarded by the enclosing `if (kl == null && _rawJson != null)` check; `!!` is safe.
- "Race condition on `numBlockActive` between UI and touch threads" — **false positive**. Both `handleKeyAction` (toggle) and `KeyboardPageView` rendering (read) run on the main thread.
### `lib-terminal-keyboard/KeyboardView.kt`
- "Rapid rebuilds via `onSizeChanged` could lose state" — **not a real issue**. The rebuild is idempotent: it re-creates the ViewPager2 and re-applies cached `lastModifierStates`. Debouncing would hide legitimate resize events.
### `lib-terminal-keyboard/QuickBarView.kt`
- "`VelocityTracker.recycle()` on line 747 is unreachable after return" — **false positive**. The recycles on 731 and 747 are in two separate `if`/`else` branches; each is reachable within its branch.
- "Empty catches around color parsing" — **intentional**. These are graceful fallbacks for user-provided custom color strings; failing silently to the theme default is the desired UX.
### `lib-terminal-keyboard/gesture/KeyRepeatHandler.kt`
- "CoroutineScope not cancelled if `destroy()` never called" — **not a real issue**. `TerminalKeyboard.detach()` calls `keyRepeatHandler.destroy()`, and `detach()` is called from the `DisposableEffect(keyboard)` onDispose in `MainActivity`.
### `lib-ssh/SftpSession.kt`
- "`sortEntries` sorts dirs by name even in `size` mode" — **intentional**. Directories in SFTP do not have a meaningful file size (usually 0 or filesystem-block size); alphabetical ordering is the sensible fallback.
- "Dispatcher leak on `connect()` failure" — **false positive**. `SftpSessionManager.openSftpSession` wraps the `connect()` call in try/catch and calls `sftpSession.close()` on failure (line 49), which shuts down the dispatcher.
### `lib-ssh/SSHSession.kt`
- "Password in `String`" — **known limitation** documented in the existing code. Strings are immutable in the JVM and cannot be reliably zeroed; the app converts them to `CharArray` at the earliest opportunity in `MainViewModel`/`TerminalService` and zeros those. Not a regression.
- "Swallowed cleanup exceptions" — **intentional**. Cleanup paths swallow to avoid cascading failures that could mask the original disconnect reason.
### `lib-vault-crypto/aes256gcm.c`
- "Table-based AES vulnerable to cache-timing side channels" — **not in threat model**. The app runs in its own Android process; there is no co-located untrusted code attempting cache-timing attacks against the vault crypto. Using a constant-time software AES would add complexity without addressing a real attack vector.
- "GHASH not constant-time" — same rationale.
### `lib-vault-crypto/VaultCrypto.kt`
- "Plaintext ByteArray parameter not zeroed by the Kotlin API" — **caller's responsibility**. The Kotlin API is a thin JNI wrapper; the caller owns the input ByteArray and is expected to zero it after use if it contains secrets. Documenting this would be useful (follow-up doc task), but the code itself is not buggy.
### `lib-terminal-view/ScreenBuffer.kt`
- "`screenRow()` returns `buf[0]` for out-of-bounds" — **defensive**, not a bug. Parser callers should never hit this path; the fallback prevents a crash in unexpected cases.
### `app/ui/MainActivity.kt`, `MainViewModel.kt`, `SessionTabBar.kt`, `EditConnectionScreen.kt`, `KeyboardSettingsDialog.kt`, `QuickBarCustomizer.kt`
- All clean per the app-module agent. HW keyboard detection, theme picker, NumBlok serialization all correct.
### `app/src/dev/DevConfig.kt`
- "Hardcoded test credentials" — **dev flavor only**, stripped from prod. Used for the internal test SSH servers (one.jima.cat, 10.10.0.39). Not a leak.
## Build verification
- `./gradlew assembleDevDebug`**BUILD SUCCESSFUL** (v0.0.31)
- Native `vault_crypto.cpp` recompiled cleanly for all ABIs (armeabi-v7a, arm64-v8a, x86, x86_64) with the added `secure_zero` calls.
- Deployed to `duero:/mnt/master/ssh-workbench.v0.0.31.dev.dbg.2026-04-06.apk`.
- S23 was disconnected at deploy time, so no install/smoke test on device. Previous v0.0.30 is still installed on S23; the audit changes are all defence-in-depth tightening and should be transparent at runtime.

View file

@ -30,8 +30,8 @@ android {
defaultConfig {
minSdk = 27
targetSdk = 36
versionCode = 20
versionName = "0.0.20"
versionCode = 31
versionName = "0.0.31"
applicationId = "com.roundingmobile.sshworkbench"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View file

@ -2,5 +2,5 @@ package com.roundingmobile.sshworkbench
// Auto-generated — do not edit
object BuildTimestamp {
const val TIME = "2026-04-06 11:23:39"
const val TIME = "2026-04-06 13:12:27"
}

View file

@ -62,7 +62,6 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@ -756,17 +755,14 @@ private fun KeyboardTab(
)
}
// Number row mode — mini numpad (left/right) only available in landscape
// Number row mode — all four options available in both orientations
SectionLabel(stringResource(R.string.number_row))
val isLandscape = LocalConfiguration.current.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
val allNumberRowOptions = listOf(
val numberRowOptions = listOf(
"top" to R.string.number_row_top,
"left" to R.string.number_row_left,
"right" to R.string.number_row_right,
"hidden" to R.string.number_row_hidden
)
val numberRowOptions = if (isLandscape) allNumberRowOptions
else allNumberRowOptions.filter { it.first != "left" && it.first != "right" }
LanguageDropdown(
options = numberRowOptions.map { it.first to stringResource(it.second) },
selected = numberRowMode,

View file

@ -203,6 +203,7 @@ private fun serializeAction(action: KeyAction): JSONObject {
is KeyAction.ToggleMod -> { obj.put("type", "toggle_mod"); obj.put("mod", action.mod) }
is KeyAction.Macro -> { obj.put("type", "macro"); obj.put("text", action.text) }
is KeyAction.SwitchPage -> { obj.put("type", "switch_page"); obj.put("pageId", action.pageId) }
is KeyAction.ToggleNumBlock -> { obj.put("type", "toggle_numblock") }
is KeyAction.None -> { obj.put("type", "none") }
}
return obj
@ -215,6 +216,7 @@ private fun deserializeAction(obj: JSONObject): KeyAction =
"esc_seq" -> KeyAction.EscSeq(obj.getString("seq"))
"combo" -> { val m = obj.getJSONArray("mod"); KeyAction.Combo((0 until m.length()).map { m.getString(it) }, obj.getString("key")) }
"toggle_mod" -> KeyAction.ToggleMod(obj.getString("mod"))
"toggle_numblock" -> KeyAction.ToggleNumBlock
"macro" -> KeyAction.Macro(obj.getString("text"))
"switch_page" -> KeyAction.SwitchPage(obj.getString("pageId"))
else -> KeyAction.None

View file

@ -297,11 +297,17 @@ class MainActivity : AppCompatActivity() {
val isTablet = LocalContext.current.resources.configuration.smallestScreenWidthDp >= 600
val sameSizeBoth by mainViewModel.terminalPrefs.keyboardSameSizeBoth.collectAsStateWithLifecycle(!isTablet)
val isLandscape = LocalConfiguration.current.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
// Hardware keyboard detection — Configuration updates live when a BT/USB keyboard
// connects or disconnects. When one is attached we default-hide the CKB and QB so
// the user sees more terminal. Manual overrides via the kebab menu still work.
val hasHwKeyboard = LocalConfiguration.current.keyboard != android.content.res.Configuration.KEYBOARD_NOKEYS &&
LocalConfiguration.current.hardKeyboardHidden != android.content.res.Configuration.HARDKEYBOARDHIDDEN_YES
val keyboardHeightPercent = if (isLandscape && !sameSizeBoth) keyboardHeightLandscape else keyboardHeightPortrait
val keyboardLanguage by mainViewModel.terminalPrefs.keyboardLanguage.collectAsStateWithLifecycle("en")
val showKeyHints by mainViewModel.terminalPrefs.showKeyHints.collectAsStateWithLifecycle(true)
val numberRowModePref by mainViewModel.terminalPrefs.numberRowMode.collectAsStateWithLifecycle("left")
// Mini numpad (left/right) only in landscape — portrait falls back to "top"
// Mini numpad is landscape-only by design — portrait always falls back to "top".
// NEVER remove this override to "fix" unrelated rotation bugs.
val numberRowMode = if (!isLandscape && (numberRowModePref == "left" || numberRowModePref == "right")) "top" else numberRowModePref
val showPageIndicators by mainViewModel.terminalPrefs.showPageIndicators.collectAsStateWithLifecycle(true)
val cqbSize by mainViewModel.terminalPrefs.quickBarSize.collectAsStateWithLifecycle(42)
@ -325,8 +331,17 @@ class MainActivity : AppCompatActivity() {
val sessionNavStyle by mainViewModel.terminalPrefs.sessionNavStyle.collectAsStateWithLifecycle("top_bar")
val isDrawerMode = sessionNavStyle == "drawer"
// Temporary hide state — tapping terminal or navigating to terminal resets it
// Temporary hide state — tapping terminal or navigating to terminal resets it.
// When a hardware keyboard is connected, this flag also gates the QuickBar
// (see the QB AndroidView update block below) and defaults to true so CKB+QB
// start hidden; user can reveal both via the kebab "Show keyboard" entry.
var ckbHidden by remember { mutableStateOf(false) }
// Reset the ckbHidden state whenever the HW keyboard attach/detach changes:
// HW kb connected → hide both by default; HW kb disconnected → show CKB again.
// User's manual show/hide between transitions is preserved.
androidx.compose.runtime.LaunchedEffect(hasHwKeyboard) {
ckbHidden = hasHwKeyboard
}
// Keyboard settings dialog state (opened from CKB gear menu)
var showKbSettings by remember { mutableStateOf(false) }
var showAqbSettings by remember { mutableStateOf(false) }
@ -665,7 +680,11 @@ class MainActivity : AppCompatActivity() {
}
val isTerminalTab = tabTypes[appState.activeSessionId] != TabType.SFTP
val qbHidden = qbPosition == "none"
container.visibility = if (quickBarVisible && showTerminal && isTerminalTab && !qbHidden) View.VISIBLE else View.GONE
// When a HW keyboard is active, the kebab Show/Hide toggle
// (ckbHidden) controls both the CKB and the QB as a pair.
// Otherwise the QB follows its own quickBarVisible preference.
val qbShownByUser = if (hasHwKeyboard) !ckbHidden else quickBarVisible
container.visibility = if (qbShownByUser && showTerminal && isTerminalTab && !qbHidden) View.VISIBLE else View.GONE
// Set orientation on the actual QuickBarView
keyboard.quickBarView?.setOrientation(
if (isVerticalQb) com.roundingmobile.keyboard.model.QuickBarOrientation.VERTICAL
@ -825,6 +844,9 @@ class MainActivity : AppCompatActivity() {
},
update = { container ->
val isTermTab = tabTypes[appState.activeSessionId] != TabType.SFTP
// ckbHidden gates CKB regardless of HW kb — the LaunchedEffect
// above seeds it to hasHwKeyboard so the default is hidden when
// a HW keyboard is connected, but user can still show via kebab.
container.visibility = if (isCustomKeyboard && !ckbHidden && showTerminal && isTermTab) android.view.View.VISIBLE else android.view.View.GONE
keyboard.setPageIndicatorVisible(showPageIndicators)
val screenHeight = container.resources.displayMetrics.heightPixels

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-03
> Last updated: 2026-04-06
---
@ -207,6 +207,10 @@ Single Activity host for the entire app. Three-layer Box architecture:
- **Layer 2**: NavHost (AnimatedVisibility) — ConnectionListScreen, EditConnectionScreen, SettingsScreen, KeyManagerScreen
- Binds to TerminalService, delegates all state management to MainViewModel
**Hardware keyboard auto-hide** — `MainActivity` derives `hasHwKeyboard` from `LocalConfiguration.current.keyboard != KEYBOARD_NOKEYS && hardKeyboardHidden != HARDKEYBOARDHIDDEN_YES`. Compose re-observes Configuration live when a BT/USB keyboard connects or disconnects (even with `configChanges` set). A `LaunchedEffect(hasHwKeyboard) { ckbHidden = hasHwKeyboard }` seeds the `ckbHidden` state on every transition, so both the CKB and the QuickBar default-hide when a hardware keyboard is present. The kebab menu's "Show keyboard" toggle controls **both** the CKB and the QuickBar together in HW keyboard mode — the QuickBar `update` block uses `val qbShownByUser = if (hasHwKeyboard) !ckbHidden else quickBarVisible`. In normal (no HW kb) mode the QuickBar follows its own `quickBarVisible` pref unchanged.
**Portrait mini override** — `MainActivity` computes `numberRowMode` as `if (!isLandscape && (pref == "left" || pref == "right")) "top" else pref`. The mini numpad is **landscape-only by design**; portrait always falls back to `top`. Do not remove this override to "fix" unrelated rotation bugs — the mini-in-portrait scenario has been intentionally disabled.
#### `MainViewModel`
Central ViewModel for the single-Activity architecture:
- `TabType` enum (`TERMINAL`, `SFTP`) and `SftpTabInfo` data class for SFTP tab metadata
@ -687,6 +691,8 @@ TerminalKeyboard (entry point, Builder)
└── ModifierStateManager (CTRL/ALT/SHIFT state machine)
```
**VP2 rotation-phantom workaround** — `KeyboardView.onSizeChanged` rebuilds the ViewPager2 (via `buildPages()`) on **any** width transition including the initial `0 → actual` layout pass. The first `buildPages()` happens from `TerminalKeyboard.attachTo()``kv.setup()` while the view is still detached (width=0), so VP2's internal RecyclerView scroll offset is unreliable; a second `buildPages()` after the real layout arrives produces a clean result. `KeyboardView.lastModifierStates` caches the current CTRL/ALT/SHIFT state between rebuilds so visual state survives. This works around [Google Issue Tracker 175796502](https://issuetracker.google.com/issues/175796502) which is still unfixed in ViewPager2 1.1.0 for non-`FragmentStateAdapter` usage.
### Data Models
**`KeyboardLayout`** — parsed from JSON:
@ -703,9 +709,11 @@ TerminalKeyboard (entry point, Builder)
}
```
**`KeyDefinition`**: `id`, `label`, `action` (KeyAction), `w/h` (proportional), `style`, `repeatable`, `longPress` (variants), `visible`.
**`KeyDefinition`**: `id`, `label`, `action` (KeyAction), `w/h` (proportional), `style`, `repeatable`, `longPress` (variants), `visible`, `numLabel` / `numAction` (alternates shown/dispatched when mini NumBlock mode is active).
**`KeyAction`** (sealed): `Char(primary, shift?)`, `Bytes(values)`, `EscSeq(seq)`, `Combo(mod, key)`, `ToggleMod(mod)`, `Macro(text)`, `SwitchPage(pageId)`, `None`.
**`KeyAction`** (sealed): `Char(primary, shift?)`, `Bytes(values)`, `EscSeq(seq)`, `Combo(mod, key)`, `ToggleMod(mod)`, `ToggleNumBlock`, `Macro(text)`, `SwitchPage(pageId)`, `None`.
**NumBlok (mini numpad only)**: The mini numpad's last row contains a `toggle_numblock` key. Tapping it flips `TerminalKeyboard.numBlockActive`; while active, `KeyboardPageView.resolveDisplayLabel` returns `key.numLabel` instead of `key.label`, `handleKeyAction` dispatches `key.numAction` instead of `key.action`, and the toggle key itself gets a LOCKED-style amber glow. Mini rows 1-3 become PC-keypad nav (`Home/↑/PgUp`, `←/Esc/→`, `End/↓/PgDn`); `m0``Ins`; `m_bslash``~`. State is carried across rotation rebuilds via `attachMiniTo` copying `numBlockActive` into the new `KeyboardPageView`. See `project_numblock_mini.md` in auto-memory.
**`KeyEvent`** (sealed): `Character`, `Bytes`, `EscapeSequence`, `Combo`, `Macro`, `Special`.

View file

@ -33,6 +33,14 @@
- ~~Vault import in-place~~ — VaultImportSheet hosted in KeysVaultScreen, no popBackStack
- ~~Clickable radio/checkbox rows~~ — VaultExportSheet, VaultImportSheet rows tappable on text
- ~~configChanges~~ — orientation|screenSize|smallestScreenSize|screenLayout prevents activity recreation on rotation
- ~~Theme picker default label~~ — EditConnection + tab bar dialogs now show actual global theme in `Default (…)` label; tab bar dialog uses all 20 themes with scrollable Default option
- ~~Drawer back button~~ — Back in terminal pane closes drawer first if drawer is open
- ~~Mini numpad 15% width~~`widthPercent` 10 → 15 in all three `layout_qwerty*.json`
- ~~VP2 rotation phantom fix~~`KeyboardView.onSizeChanged` rebuilds VP2 on any width transition including initial 0→actual layout; `lastModifierStates` cached for re-apply (Google Issue 175796502 workaround)
- ~~Number row dropdown in portrait~~`KeyboardSettingsDialog` now shows all 4 options (top/left/right/hidden) regardless of orientation
- ~~NumBlok toggle on mini numpad~~`KeyDefinition.numLabel`/`numAction` fields, `KeyAction.ToggleNumBlock`; mini last row has `Num 0 \` which becomes PC-keypad nav when toggled
- ~~Hardware keyboard auto-hide~~`Configuration.keyboard` detection; CKB + QB hidden by default when BT/USB keyboard connects; kebab Show/Hide toggles both as a pair in HW kb mode
- ~~Audit 2026-04-06~~`KeyboardPageView` smart-cast `!!` removal, `SSHKeyLoader` Ed25519 seed length require, `vault_crypto.cpp` plaintext `secure_zero` before `ReleaseByteArrayElements` in `nativeEncrypt`
## Open

View file

@ -98,6 +98,7 @@ object SSHKeyLoader {
*/
private fun loadEd25519FromPkcs8(info: PrivateKeyInfo): KeyProvider {
val seed = ASN1OctetString.getInstance(info.parsePrivateKey()).octets
require(seed.size == 32) { "Ed25519 seed must be exactly 32 bytes, got ${seed.size}" }
val edSpec = EdDSANamedCurveTable.getByName("Ed25519")
val privSpec = EdDSAPrivateKeySpec(seed, edSpec)
val privKey = EdDSAPrivateKey(privSpec)

View file

@ -278,6 +278,7 @@ class TerminalKeyboard private constructor(
val page = KeyboardPage(id = "mini", name = "Mini", rows = mini.rows)
val pv = KeyboardPageView(context, page, theme)
pv.setOnTouchListener { _, event -> handlePageTouch(pv, event) }
pv.numBlockActive = numBlockActive // carry over across re-attach
container.addView(pv, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
@ -810,6 +811,8 @@ class TerminalKeyboard private constructor(
}
private fun resolveHintLabel(key: KeyDefinition): String {
// Mini NumBlock mode: show the nav label instead of the digit
if (numBlockActive && key.numLabel != null) return key.numLabel
val action = key.action
if (action is KeyAction.Char && action.shift != null) {
val shiftState = modifierManager.states.value[Modifier.SHIFT]
@ -822,8 +825,12 @@ class TerminalKeyboard private constructor(
// --- Action resolution ---
/** NumBlock mode flag — toggled by a ToggleNumBlock key, controls the mini numpad's alternate action/label. */
private var numBlockActive: Boolean = false
private fun handleKeyAction(key: KeyDefinition) {
val action = key.action
// If NumBlock is active and the key has a numAction, use that instead of the base action.
val action = if (numBlockActive && key.numAction != null) key.numAction else key.action
val activeModifiers = modifierManager.activeModifiers()
val shiftActive = Modifier.SHIFT in activeModifiers
@ -870,6 +877,10 @@ class TerminalKeyboard private constructor(
is KeyAction.SwitchPage -> {
keyboardView?.switchToPage(action.pageId)
}
is KeyAction.ToggleNumBlock -> {
numBlockActive = !numBlockActive
miniPageView?.numBlockActive = numBlockActive
}
is KeyAction.None -> {}
}
}

View file

@ -22,6 +22,9 @@ sealed class KeyAction {
/** Switch keyboard page. */
data class SwitchPage(val pageId: String) : KeyAction()
/** Toggle the mini numpad's NumBlok mode (keys 1-9 become PC keypad nav keys). */
data object ToggleNumBlock : KeyAction()
/** No action — spacer/decorative key. */
data object None : KeyAction()
}

View file

@ -21,7 +21,11 @@ data class KeyDefinition(
val repeatable: Boolean = false,
val longPress: List<String>? = null,
val menuItems: List<MenuItem>? = null,
val visible: Boolean = true
val visible: Boolean = true,
/** Alternate label shown when the mini numpad's NumBlok mode is active. */
val numLabel: String? = null,
/** Alternate action emitted when NumBlok is active (overrides `action`). */
val numAction: KeyAction? = null
) {
/** Computed pixel bounds after layout pass — used for hit testing and drawing */
var bounds: RectF = RectF()

View file

@ -95,7 +95,9 @@ object LayoutParser {
repeatable = obj.optBoolean("repeatable", false),
longPress = parseStringList(obj.optJSONArray("longPress")),
menuItems = parseMenuItems(obj.optJSONArray("menuItems")),
visible = obj.optBoolean("visible", true)
visible = obj.optBoolean("visible", true),
numLabel = obj.optString("numLabel", null),
numAction = obj.optJSONObject("numAction")?.let { parseAction(it) }
)
}
@ -118,6 +120,7 @@ object LayoutParser {
)
}
"toggle_mod" -> KeyAction.ToggleMod(obj.getString("mod"))
"toggle_numblock" -> KeyAction.ToggleNumBlock
"macro" -> KeyAction.Macro(obj.getString("text"))
"switch_page" -> KeyAction.SwitchPage(obj.getString("pageId"))
"none" -> KeyAction.None

View file

@ -30,6 +30,10 @@ class KeyboardPageView(
var modifierStates: Map<Modifier, ModifierState> = emptyMap()
set(value) { field = value; invalidate() }
/** NumBlock mode — when true, keys with a numLabel/numAction render and dispatch those instead. */
var numBlockActive: Boolean = false
set(value) { field = value; invalidate() }
// Paint objects reused across draws
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = 1f }
@ -166,14 +170,19 @@ class KeyboardPageView(
} else if (modState == ModifierState.LOCKED) {
canvas.drawRoundRect(rect, radiusPx, radiusPx, lockedPaint)
}
// NumBlock toggle key: LOCKED-style amber glow when active
if (key.action is KeyAction.ToggleNumBlock && numBlockActive) {
canvas.drawRoundRect(rect, radiusPx, radiusPx, lockedPaint)
}
// Main label — show uppercase when Shift is active
labelPaint.color = theme.resolveKeyText(key.style)
labelPaint.isFakeBoldText = pressed
val displayLabel = resolveDisplayLabel(key)
val fm = labelPaint.fontMetrics
val hasCornerHint = key.hint != null && key.hintColor != 0
val hasBelowHint = key.hint != null && key.hintColor == 0
val hintText = key.hint // capture once so smart-cast works through the render branches
val hasCornerHint = hintText != null && key.hintColor != 0
val hasBelowHint = hintText != null && key.hintColor == 0
val labelY = if (hasBelowHint) {
// Label + hint below: label in upper portion, hint below
val totalH = -fm.ascent + fm.descent + hintPaint.textSize
@ -186,27 +195,29 @@ class KeyboardPageView(
canvas.drawText(displayLabel, rect.centerX(), labelY, labelPaint)
// Hint label
if (hasBelowHint) {
if (hintText != null && hasBelowHint) {
hintPaint.color = theme.keyHintText
val hintFm = hintPaint.fontMetrics
val hintY = labelY + fm.descent + (-hintFm.ascent)
canvas.drawText(key.hint!!, rect.centerX(), hintY, hintPaint)
} else if (hasCornerHint) {
canvas.drawText(hintText, rect.centerX(), hintY, hintPaint)
} else if (hintText != null && hasCornerHint) {
// Corner hint: top-right of key, smaller font
hintPaint.color = key.hintColor
hintPaint.textAlign = Paint.Align.RIGHT
val hintFm = hintPaint.fontMetrics
val hintY = rect.top + paddingPx - hintFm.ascent
canvas.drawText(key.hint!!, rect.right - paddingPx, hintY, hintPaint)
canvas.drawText(hintText, rect.right - paddingPx, hintY, hintPaint)
hintPaint.textAlign = Paint.Align.CENTER // restore
}
}
/**
* Returns the label to display, respecting Shift state.
* When Shift is ARMED or LOCKED, character keys show their uppercase/shift variant.
* Returns the label to display, respecting NumBlock and Shift state.
* When NumBlock is active and the key has a numLabel, it wins over the base label and Shift.
* When Shift is ARMED/LOCKED, character keys show their uppercase/shift variant.
*/
private fun resolveDisplayLabel(key: KeyDefinition): String {
if (numBlockActive && key.numLabel != null) return key.numLabel
val action = key.action
if (action is KeyAction.Char && action.shift != null) {
val shiftState = modifierStates[Modifier.SHIFT]

View file

@ -189,7 +189,54 @@ class KeyboardView(context: Context) : FrameLayout(context) {
fun getPageCount(): Int = realPageCount
fun getCurrentPageIndex(): Int = currentRealIndex
/** Cached modifier states so we can re-apply them after a full VP2 rebuild in onSizeChanged. */
private var lastModifierStates: Map<Modifier, ModifierState> = emptyMap()
/**
* Google Issue Tracker 175796502 after the ViewPager2's width changes (rotation
* when Activity has configChanges=orientation, split-screen resize, etc.) its
* internal RecyclerView ends up at a fractional scroll offset, leaving the neighbor
* page bleeding in from the side. Confirmed still unfixed in VP2 1.1.0.
*
* Softer workarounds tried and confirmed ineffective:
* - setCurrentItem(currentItem, false) no-op (VP2 early-returns when item==current)
* - LinearLayoutManager.scrollToPositionWithOffset(currentItem, 0) runs, but
* VP2's own post-measure logic and the cached RV children override it; phantom
* stays on screen (verified on S23 with logcat proving the call fires).
*
* Nuclear fix: fully rebuild the ViewPager2 from scratch. Same code path as fresh
* launch, which we know produces a correct layout. Cost is a single extra layout
* pass on rotation invisible to the user.
*/
/**
* Rebuild the ViewPager2 on every width change, including the initial 0actual layout
* pass. The first buildPages() (triggered from setup()) happens before the view has
* real dimensions (w=0), so VP2 is created with zero-width pages. When a size later
* arrives, VP2 does not reliably re-anchor its scroll offset the neighbor page
* leaks in from the side (Google Issue Tracker 175796502, still unfixed in VP2 1.1.0
* for non-FragmentStateAdapter usage). A second buildPages() at the real dimensions
* produces a clean layout regardless of VP2's broken resize path.
*
* The cost is one extra VP2 construction on startup and on every genuine resize
* cheap (3 pages, Canvas-drawn). The previous KeyboardView instance's modifier states
* are re-applied from the cached `lastModifierStates` field.
*/
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (w > 0 && w != oldw && layout != null) {
val savedIdx = currentRealIndex
post {
buildPages()
if (lastModifierStates.isNotEmpty()) {
for (pv in allPageViews) pv.modifierStates = lastModifierStates
}
if (savedIdx > 0) switchToPage(savedIdx)
}
}
}
fun setModifierStates(states: Map<Modifier, ModifierState>) {
lastModifierStates = states
for (pv in allPageViews) {
pv.modifierStates = states
}

View file

@ -205,22 +205,35 @@
"widthPercent": 15,
"rows": [
{ "keys": [
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },
{ "id": "m2", "label": "2", "action": { "type": "char", "primary": "2", "shift": "@" } },
{ "id": "m3", "label": "3", "action": { "type": "char", "primary": "3", "shift": "#" } }
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" },
"numLabel": "End", "numAction": { "type": "esc_seq", "seq": "[F" } },
{ "id": "m2", "label": "2", "action": { "type": "char", "primary": "2", "shift": "@" },
"numLabel": "↓", "numAction": { "type": "esc_seq", "seq": "[B" } },
{ "id": "m3", "label": "3", "action": { "type": "char", "primary": "3", "shift": "#" },
"numLabel": "PgDn", "numAction": { "type": "esc_seq", "seq": "[6~" } }
]},
{ "keys": [
{ "id": "m4", "label": "4", "action": { "type": "char", "primary": "4", "shift": "$" } },
{ "id": "m5", "label": "5", "action": { "type": "char", "primary": "5", "shift": "%" } },
{ "id": "m6", "label": "6", "action": { "type": "char", "primary": "6", "shift": "^" } }
{ "id": "m4", "label": "4", "action": { "type": "char", "primary": "4", "shift": "$" },
"numLabel": "←", "numAction": { "type": "esc_seq", "seq": "[D" } },
{ "id": "m5", "label": "5", "action": { "type": "char", "primary": "5", "shift": "%" },
"numLabel": "Esc", "numAction": { "type": "bytes", "value": [27] } },
{ "id": "m6", "label": "6", "action": { "type": "char", "primary": "6", "shift": "^" },
"numLabel": "→", "numAction": { "type": "esc_seq", "seq": "[C" } }
]},
{ "keys": [
{ "id": "m7", "label": "7", "action": { "type": "char", "primary": "7", "shift": "&" } },
{ "id": "m8", "label": "8", "action": { "type": "char", "primary": "8", "shift": "*" } },
{ "id": "m9", "label": "9", "action": { "type": "char", "primary": "9", "shift": "(" } }
{ "id": "m7", "label": "7", "action": { "type": "char", "primary": "7", "shift": "&" },
"numLabel": "Home", "numAction": { "type": "esc_seq", "seq": "[H" } },
{ "id": "m8", "label": "8", "action": { "type": "char", "primary": "8", "shift": "*" },
"numLabel": "↑", "numAction": { "type": "esc_seq", "seq": "[A" } },
{ "id": "m9", "label": "9", "action": { "type": "char", "primary": "9", "shift": "(" },
"numLabel": "PgUp", "numAction": { "type": "esc_seq", "seq": "[5~" } }
]},
{ "keys": [
{ "id": "m0", "label": "0", "action": { "type": "char", "primary": "0", "shift": ")" } }
{ "id": "m_numblock", "label": "Num", "action": { "type": "toggle_numblock" }, "style": "modifier" },
{ "id": "m0", "label": "0", "action": { "type": "char", "primary": "0", "shift": ")" },
"numLabel": "Ins", "numAction": { "type": "esc_seq", "seq": "[2~" } },
{ "id": "m_bslash", "label": "\\", "action": { "type": "char", "primary": "\\", "shift": "|" },
"numLabel": "~", "numAction": { "type": "char", "primary": "~" } }
]}
]
},

View file

@ -206,22 +206,35 @@
"widthPercent": 15,
"rows": [
{ "keys": [
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },
{ "id": "m2", "label": "2", "action": { "type": "char", "primary": "2", "shift": "@" } },
{ "id": "m3", "label": "3", "action": { "type": "char", "primary": "3", "shift": "#" } }
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" },
"numLabel": "End", "numAction": { "type": "esc_seq", "seq": "[F" } },
{ "id": "m2", "label": "2", "action": { "type": "char", "primary": "2", "shift": "@" },
"numLabel": "↓", "numAction": { "type": "esc_seq", "seq": "[B" } },
{ "id": "m3", "label": "3", "action": { "type": "char", "primary": "3", "shift": "#" },
"numLabel": "PgDn", "numAction": { "type": "esc_seq", "seq": "[6~" } }
]},
{ "keys": [
{ "id": "m4", "label": "4", "action": { "type": "char", "primary": "4", "shift": "$" } },
{ "id": "m5", "label": "5", "action": { "type": "char", "primary": "5", "shift": "%" } },
{ "id": "m6", "label": "6", "action": { "type": "char", "primary": "6", "shift": "^" } }
{ "id": "m4", "label": "4", "action": { "type": "char", "primary": "4", "shift": "$" },
"numLabel": "←", "numAction": { "type": "esc_seq", "seq": "[D" } },
{ "id": "m5", "label": "5", "action": { "type": "char", "primary": "5", "shift": "%" },
"numLabel": "Esc", "numAction": { "type": "bytes", "value": [27] } },
{ "id": "m6", "label": "6", "action": { "type": "char", "primary": "6", "shift": "^" },
"numLabel": "→", "numAction": { "type": "esc_seq", "seq": "[C" } }
]},
{ "keys": [
{ "id": "m7", "label": "7", "action": { "type": "char", "primary": "7", "shift": "&" } },
{ "id": "m8", "label": "8", "action": { "type": "char", "primary": "8", "shift": "*" } },
{ "id": "m9", "label": "9", "action": { "type": "char", "primary": "9", "shift": "(" } }
{ "id": "m7", "label": "7", "action": { "type": "char", "primary": "7", "shift": "&" },
"numLabel": "Home", "numAction": { "type": "esc_seq", "seq": "[H" } },
{ "id": "m8", "label": "8", "action": { "type": "char", "primary": "8", "shift": "*" },
"numLabel": "↑", "numAction": { "type": "esc_seq", "seq": "[A" } },
{ "id": "m9", "label": "9", "action": { "type": "char", "primary": "9", "shift": "(" },
"numLabel": "PgUp", "numAction": { "type": "esc_seq", "seq": "[5~" } }
]},
{ "keys": [
{ "id": "m0", "label": "0", "action": { "type": "char", "primary": "0", "shift": ")" } }
{ "id": "m_numblock", "label": "Num", "action": { "type": "toggle_numblock" }, "style": "modifier" },
{ "id": "m0", "label": "0", "action": { "type": "char", "primary": "0", "shift": ")" },
"numLabel": "Ins", "numAction": { "type": "esc_seq", "seq": "[2~" } },
{ "id": "m_bslash", "label": "\\", "action": { "type": "char", "primary": "\\", "shift": "|" },
"numLabel": "~", "numAction": { "type": "char", "primary": "~" } }
]}
]
},

View file

@ -208,22 +208,35 @@
"widthPercent": 15,
"rows": [
{ "keys": [
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },
{ "id": "m2", "label": "2", "action": { "type": "char", "primary": "2", "shift": "@" } },
{ "id": "m3", "label": "3", "action": { "type": "char", "primary": "3", "shift": "#" } }
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" },
"numLabel": "End", "numAction": { "type": "esc_seq", "seq": "[F" } },
{ "id": "m2", "label": "2", "action": { "type": "char", "primary": "2", "shift": "@" },
"numLabel": "↓", "numAction": { "type": "esc_seq", "seq": "[B" } },
{ "id": "m3", "label": "3", "action": { "type": "char", "primary": "3", "shift": "#" },
"numLabel": "PgDn", "numAction": { "type": "esc_seq", "seq": "[6~" } }
]},
{ "keys": [
{ "id": "m4", "label": "4", "action": { "type": "char", "primary": "4", "shift": "$" } },
{ "id": "m5", "label": "5", "action": { "type": "char", "primary": "5", "shift": "%" } },
{ "id": "m6", "label": "6", "action": { "type": "char", "primary": "6", "shift": "^" } }
{ "id": "m4", "label": "4", "action": { "type": "char", "primary": "4", "shift": "$" },
"numLabel": "←", "numAction": { "type": "esc_seq", "seq": "[D" } },
{ "id": "m5", "label": "5", "action": { "type": "char", "primary": "5", "shift": "%" },
"numLabel": "Esc", "numAction": { "type": "bytes", "value": [27] } },
{ "id": "m6", "label": "6", "action": { "type": "char", "primary": "6", "shift": "^" },
"numLabel": "→", "numAction": { "type": "esc_seq", "seq": "[C" } }
]},
{ "keys": [
{ "id": "m7", "label": "7", "action": { "type": "char", "primary": "7", "shift": "&" } },
{ "id": "m8", "label": "8", "action": { "type": "char", "primary": "8", "shift": "*" } },
{ "id": "m9", "label": "9", "action": { "type": "char", "primary": "9", "shift": "(" } }
{ "id": "m7", "label": "7", "action": { "type": "char", "primary": "7", "shift": "&" },
"numLabel": "Home", "numAction": { "type": "esc_seq", "seq": "[H" } },
{ "id": "m8", "label": "8", "action": { "type": "char", "primary": "8", "shift": "*" },
"numLabel": "↑", "numAction": { "type": "esc_seq", "seq": "[A" } },
{ "id": "m9", "label": "9", "action": { "type": "char", "primary": "9", "shift": "(" },
"numLabel": "PgUp", "numAction": { "type": "esc_seq", "seq": "[5~" } }
]},
{ "keys": [
{ "id": "m0", "label": "0", "action": { "type": "char", "primary": "0", "shift": ")" } }
{ "id": "m_numblock", "label": "Num", "action": { "type": "toggle_numblock" }, "style": "modifier" },
{ "id": "m0", "label": "0", "action": { "type": "char", "primary": "0", "shift": ")" },
"numLabel": "Ins", "numAction": { "type": "esc_seq", "seq": "[2~" } },
{ "id": "m_bslash", "label": "\\", "action": { "type": "char", "primary": "\\", "shift": "|" },
"numLabel": "~", "numAction": { "type": "char", "primary": "~" } }
]}
]
},

View file

@ -137,6 +137,7 @@ Java_com_roundingmobile_vaultcrypto_VaultCrypto_nativeEncrypt(
jbyte *keyPtr = env->GetByteArrayElements(key, nullptr);
if (!keyPtr) {
LOGE("Failed to get key bytes");
secure_zero(ptPtr, ptLen);
env->ReleaseByteArrayElements(plaintext, ptPtr, JNI_ABORT);
return nullptr;
}
@ -149,6 +150,7 @@ Java_com_roundingmobile_vaultcrypto_VaultCrypto_nativeEncrypt(
LOGE("Failed to read /dev/urandom");
if (urandom) fclose(urandom);
secure_zero(keyPtr, keyLen);
secure_zero(ptPtr, ptLen);
env->ReleaseByteArrayElements(plaintext, ptPtr, JNI_ABORT);
env->ReleaseByteArrayElements(key, keyPtr, JNI_ABORT);
return nullptr;
@ -173,6 +175,7 @@ Java_com_roundingmobile_vaultcrypto_VaultCrypto_nativeEncrypt(
secure_zero(tag, sizeof(tag));
secure_zero(nonce, sizeof(nonce));
secure_zero(keyPtr, keyLen);
secure_zero(ptPtr, ptLen); // plaintext in the JNI copy — defence-in-depth
env->ReleaseByteArrayElements(plaintext, ptPtr, JNI_ABORT);
env->ReleaseByteArrayElements(key, keyPtr, JNI_ABORT);