Fifth full codebase audit across all five modules (lib-ssh, lib-terminal-view,
lib-terminal-keyboard, lib-vault-crypto, app).
Security:
- Added cppFlags to lib-vault-crypto build — vault_crypto.cpp JNI bridge was
missing all compiler hardening flags (-fstack-protector-strong, -D_FORTIFY_SOURCE=2)
Bugs fixed:
- SessionNotifier crash: first{} → firstOrNull to prevent NoSuchElementException
- Keyboard modifiers not consumed on SwitchPage/ToggleNumBlock — armed CTRL/ALT
would persist and incorrectly modify the next key press
- KeyManagerViewModel silent exception swallow — now logs errors via FileLogger
- TelnetSession.sendTerminalType() variable shadowing fix
Dead code removed:
- Vt100Parser empty class (Vt220Parser now extends BaseTermParser directly)
- XtermParser.sendPrimaryDA() redundant override (identical to parent)
- TerminalKeyboard dead fields: menuPopupActive, menuPopupItems, miniContainer
- SpecialAction.SETTINGS_OPENED never emitted
- Deprecated 3-arg saveHostKeyFingerprint overload (no callers)
Code quality:
- Color(0xFF6E7979) → AppColors.Muted in ConnectionListScreen
- Hardcoded "v1.0.0" → BuildConfig.VERSION_NAME in SettingsScreen
- SubscriptionScreen back button contentDescription for accessibility
- TAG → companion const val in StartupCommandRunner, PortForwardManager, SftpSessionManager
- TerminalRenderer swapped KDoc comments fixed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
61 KiB
SSH Workbench — Technical Reference
Package:
com.roundingmobile.sshworkbenchDeveloper: Rounding Mobile Technologies S.L. Min SDK: 27 (Android 8.1) | Target SDK: 36 (Android 16) Last updated: 2026-04-06
Architecture
Five Gradle modules, zero inter-library coupling:
app/ ← Glue code: UI, DI, session management
lib-ssh/ ← SSHJ wrapper (coroutines API)
lib-terminal-view/ ← VT100/VT220/xterm engine + Canvas renderer
lib-terminal-keyboard/ ← Custom Canvas keyboard, JSON layouts
lib-vault-crypto/ ← Argon2id + AES-256-GCM via JNI (vault encryption)
Stack: Kotlin, Coroutines, Hilt (DI), Room (DB), DataStore (prefs), Jetpack Compose (settings/connection screens), traditional Views (terminal screen), SSHJ (SSH), BouncyCastle (crypto), ZXing (QR), Firebase Analytics + Crashlytics.
Module: lib-vault-crypto
Standalone C/C++ cryptography library for vault operations. JNI bridge to Kotlin.
Native Code (src/main/cpp/)
| File | Purpose |
|---|---|
argon2/argon2.h / core.c |
Argon2id key derivation (RFC 9106) |
argon2/blake2b.c / blake2.h |
BLAKE2b hash + variable-length H' |
argon2/ref.c |
Reference fill-segment with BlaMka mixing |
aes256gcm.c / aes256gcm.h |
AES-256-GCM encrypt/decrypt (NIST SP 800-38D). All sensitive locals (ghash, enc_j0, J0, ICB, computed_tag, counter) zeroed via secure_zero before return |
vault_crypto.cpp |
JNI bridge — exposes three functions to Kotlin. Uses secure_zero (volatile function pointer trick to prevent dead-store elimination) for all security-critical memory clearing. Password buffers zeroed before JNI release. Key material zeroed after use. Null checks on all JNI array access |
Kotlin API (VaultCrypto.kt)
object VaultCrypto {
fun deriveKey(password: CharArray, salt: ByteArray): ByteArray? // require(salt.size >= 16)
fun encrypt(plaintext: ByteArray, key: ByteArray): ByteArray? // require(key.size == 32), returns [nonce(12) || ct || tag(16)]
fun decrypt(ciphertext: ByteArray, key: ByteArray): ByteArray? // require(key.size == 32), takes [nonce(12) || ct || tag(16)]
}
Input validation: encrypt/decrypt require exactly 32-byte keys, deriveKey requires salt >= 16 bytes. All throw IllegalArgumentException on violation.
Argon2id parameters (constants in vault_crypto.cpp):
- Memory: 65536 KiB (64 MB)
- Iterations: 3
- Parallelism: 1
- Output: 32 bytes (256-bit key)
Module: app
Data Layer
Room Database — AppDatabase
- Version: 11 (migrations 1→2 through 10→11)
- Entities:
SavedConnection,SshKey,Snippet,PortForward - DAOs:
SavedConnectionDao,SshKeyDao,SnippetDao,PortForwardDao - MIGRATION_8_9: Adds
autoReconnectcolumn (INTEGER, default 1) tosaved_connections - MIGRATION_9_10: Creates
port_forwardstable (id, connectionId FK, type, label, bindAddress, localPort, remoteHost, remotePort) with CASCADE delete and index on connectionId - MIGRATION_10_11: Adds
fontSizeSpcolumn (REAL, default 0) tosaved_connectionsfor per-host zoom persistence
Hilt provides a singleton via DatabaseModule. TerminalService uses @AndroidEntryPoint and receives DAOs via Hilt injection (shared singleton).
SavedConnection (Room Entity)
Stores SSH/Telnet/Local connection profiles.
| Field | Type | Purpose |
|---|---|---|
id |
Long (PK, auto) | Unique ID |
name |
String | Display name (user@host) |
host, port, username |
String/Int | Connection target |
authType |
String | "password" or "key" |
protocol |
String | "ssh", "telnet", "local" |
keyId |
Long | FK to SshKey (0 = none) |
jumpHostId |
Long? | FK to another SavedConnection (ProxyJump) |
agentForwarding |
Boolean | SSH agent forwarding |
autoReconnect |
Boolean | Auto-reconnect on disconnect |
wifiLock |
Boolean | Acquire WiFi lock during session |
startupCommand |
String | Commands to run after connect |
themeName |
String | Per-connection terminal theme |
keyboardSettings |
String | Per-connection keyboard config |
fontSizeSp |
Float | Per-host zoom level (0 = default) |
lastSessionStart/End/DurationMs |
Long | Session tracking |
PortForward (Room Entity)
SSH port forwarding rules. Linked to SavedConnection via connectionId FK with CASCADE delete.
| Field | Type | Purpose |
|---|---|---|
id |
Long (PK, auto) | Unique ID |
connectionId |
Long (FK) | Parent connection |
type |
String | "LOCAL", "REMOTE", or "DYNAMIC" (SOCKS5) |
label |
String | Display name |
bindAddress |
String | Bind address (default 127.0.0.1) |
localPort |
Int | Local port number |
remoteHost |
String | Remote host (empty for DYNAMIC) |
remotePort |
Int | Remote port number (0 for DYNAMIC) |
Tunnel activation: On SessionState.Connected, TerminalService.openPortForwards() reads configured forwards from Room and opens SSHJ tunnels:
- LOCAL:
SSHClient.newLocalPortForwarder()— binds local port, forwards to remote host:port - REMOTE:
SSHClient.remotePortForwarder.bind()— binds remote port, forwards to local address:port - DYNAMIC (SOCKS5):
Socks5Proxy— binds local port as a SOCKS5 proxy server; each client connection opens an SSH direct-tcpip channel (SSHClient.newDirectConnection()) to the requested destination. Default port: 1080
All forwarders are stored in SessionEntry.activeForwarders and closed on disconnect.
SavedConnectionDao
getAll()—Flow<List<SavedConnection>>sorted bylastConnected DESCfindByHostPortUser(host, port, username)— deduplication on quick-connectgetById(id)— single lookupupdateLastConnected(id, timestamp)— touch on connectupdateSessionStart/End(id, ...)— session duration tracking
SshKey (Room Entity)
SSH public keys (Ed25519, RSA). Private key PEM stored in CredentialStore (encrypted), not Room.
Snippet (Room Entity)
Terminal command snippets. connectionId null = global, non-null = per-connection. useCount + lastUsed for sorting by frequency.
CredentialStore
EncryptedSharedPreferences (AES-256-GCM) for passwords, private keys, and TOFU host key fingerprints.
savePassword(connectionId, password)/getPassword(connectionId)/deletePassword(connectionId)saveString(key, value)/getString(key)— used forkey_private_$keyIdsaveHostKeyFingerprint(host, port, keyType, fingerprint)/getHostKeyFingerprint(host, port)/matchesStoredHostKey(host, port, keyType, fingerprint)
Passwords are only saved when explicitly requested (via "Remember password" checkbox in auth dialog or via the edit connection screen). deletePassword() is called when the password field is cleared in the connection editor.
TerminalPreferences (DataStore)
60+ preference keys covering display, keyboard, session recovery, and keyboard customization.
Key groups:
- Display:
fontSizeSp,themeName,scrollbackLines,keepScreenOn - Keyboard:
keyboardType,keyboardLanguage,hapticFeedback,quickBarVisible - Keyboard customization:
keyboardHeightPercent,keyboardHeightLandscape,keyboardSameSizeBoth,quickBarPosition,quickBarSize,keyColorPreset,keyColorCustom,qbColorPreset,qbColorCustom,showPageIndicators - Connection:
autoReconnect,wifiLockDefault - Security:
biometricLock - Session recovery:
RECOVERY_SESSION_ID,RECOVERY_HOST,RECOVERY_PORT,RECOVERY_USERNAME,RECOVERY_CONNECT_TIME,RECOVERY_PROTOCOL - Quick-connect history (pro):
QUICK_CONNECT_HISTORY— JSON array of{a: address, t: timestamp}objects, max 100 entries, 20 shown in dropdown
Each setting has a Flow<T> getter and suspend fun set*(value) setter.
Dependency Injection (Hilt)
DatabaseModule
Provides: AppDatabase, SavedConnectionDao, SshKeyDao, SnippetDao, PortForwardDao, TerminalPreferences.
All singletons except DAOs (scoped to database lifecycle).
StringResolverModule
Provides: StringResolver — allows ViewModels to resolve string resources without holding a Context. Backed by ContextStringResolver which wraps Application.getString(). Injected into ViewModels that need localized error messages or labels.
Authentication
BiometricAuthManager
Tiered timeout: 5-minute session after successful auth. Uses BiometricPrompt with BIOMETRIC_STRONG | DEVICE_CREDENTIAL.
promptForSession(activity, onSuccess, onFailed)— app unlockpromptForSensitiveAction(activity, reason, onSuccess)— password reveal, key exportisAuthExpired()— check if re-auth needed
Cryptography
KeyGenerator
Generates Ed25519 and RSA-4096 keypairs using BouncyCastle.
generateEd25519(name)→GeneratedKey(publicKeyOpenSSH, privateKeyPem, fingerprint)generateRsa(name, bits)→ same- Fingerprint: SHA-256 of public key bytes, hex-encoded
Pro Features
ProFeatures (Interface)
Feature flags for free/pro tiers. Two implementations in free/ and pro/ source sets.
| Flag | Free | Pro |
|---|---|---|
keyboardCustomization |
false | true |
languagePacks |
false | true |
biometricLock |
false | true |
startupCommands |
false | true |
agentForwarding |
false | true |
sessionTracking |
false | true |
saveBuffer |
false | true |
mouseSupport |
false | true |
perConnectionSnippets |
false | true |
quickConnectHistory |
false | true |
maxSnippets() |
20 | unlimited |
maxJumpHops() |
1 | unlimited |
showUpgradePrompt(context, featureName) — sets upgradePromptFeature state, observed by a Compose AlertDialog in MainActivity. dismissUpgradePrompt() clears the state. Play Store URL in ProFeatures.Companion.PLAY_STORE_URL.
UI — Single Activity Architecture
MainActivity
Single Activity host for the entire app. Three-layer Box architecture:
- Layer 1: Terminal surfaces (
TerminalPaneper session) + SFTP tabs (SftpScreen) +SessionTabBar— always in tree, visibility toggled viaalpha(0f). IteratestabOrder, rendersTerminalPaneorSftpScreenbased ontabTypes.key(sid)for stable Compose identity,View.INVISIBLEpreserves SurfaceView surfaces. Active session getszIndex(1f)to ensure it receives touch events regardless of Compose stacking order (INVISIBLEAndroidViewwrappers can intercept pointer events). - 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:
TabTypeenum (TERMINAL,SFTP) andSftpTabInfodata class for SFTP tab metadata- Pane switching (NAV_HOST ↔ TERMINAL)
- Session observation from TerminalService.activeSessions
- Tab management via
tabOrder: StateFlow<List<Long>>(order-sensitive, avoidsMap.equals()conflation) tabTypes,sftpTabs,sessionLabels,sessionThemesStateFlows- Connect/disconnect/reconnect flow (reconnect handles SSH, Telnet, and Local shell types)
- SFTP tab lifecycle:
openSftpTab()/closeSftpTab()— independent from SSH sessions - Session label resolution with duplicate numbering (regex-based
(N)suffix scanning) - Per-session font size persistence: three-tier (View → SessionEntry → Room DB)
- Per-connection theme resolution (SavedConnection.themeName or per-session override)
- Session tracking (Pro) — updateSessionStart/End on connect/disconnect
TerminalPane
Compose wrapper for the terminal surface + quick bar + custom keyboard:
- AndroidView wrapping LinearLayout: TerminalSurfaceView (weight=1) + QuickBarView + KeyboardView
- TerminalKeyboard built from JSON layout with language pack
- Key events collected via SharedFlow → onInput → TerminalService.writeInput()
- Keyboard mode guard:
softInputEnabled = !isCustomprevents dual-keyboard state (CKB + AKB showing simultaneously) - Connecting spinner overlay with delayed Cancel button
- DisconnectedBar overlay with Reconnect/Save Buffer/Close actions
- Per-connection theme override via themeOverride parameter
- Per-session zoom persistence: factory reads from SessionEntry, onZoomResize saves back
- Sets localized strings on TerminalSurfaceView (copied-lines text, new-output format, toolbar labels)
SessionTabBar
Pure Compose tab bar: 41dp height, 135dp chips, + button for new sessions. 3-dot overflow menu with Duplicate, SFTP, Connect to Terminal (SFTP tabs only), Rename, Theme, Close actions. Auto-scrolls to active tab.
Tab color scheme — type-specific colors preserved across all states:
| State | SSH | SFTP | Telnet |
|---|---|---|---|
| Active (selected) | Teal bg + teal text/border | Amber bg + amber text/border | Violet bg + violet text/border |
| Inactive (not selected) | Subtle teal bg + gray text | Subtle amber bg + amber 60% text | Subtle violet bg + violet 60% text |
| Disconnected active | Dimmed teal bg + teal 50% + red dot | Dimmed amber bg + amber 50% + red dot | Dimmed violet bg + violet 50% + red dot |
| Disconnected inactive | Neutral bg + teal 35% + red dot | Subtle amber bg + amber 35% + red dot | Subtle violet bg + violet 35% + red dot |
Disconnected tabs show a 6dp red dot before the label as a universal disconnect indicator, while preserving type identity through color.
ConnectionListScreen
Connection list with:
- Quick-connect parser (
user@host:port) - Session count badges per connection
- Long-press context menu: New Session, New SFTP Session (amber, folder icon, requires connected SSH session), Disconnect All, Edit, Duplicate, Delete
- Session picker bottom sheet (2+ sessions): session list with state/elapsed, Open new session, New SFTP Session, Disconnect All
- Context menu: "New Session" (green, with + icon) when connection has active sessions, "Connect" otherwise. Also: Disconnect All, Edit, Duplicate, Delete
- Color-coded connection cards (rendered by
ConnectionItemCard.kt) - Time formatting utilities in
TimeFormatUtils.kt(formatTimestamp, formatDuration, formatRelativeTime, etc.)
EditConnectionScreen
Connection editor:
- Protocol selector (SSH/Telnet/Local)
- Auth type (Password/SSH Key) with biometric-protected password reveal
- Jump host dropdown with cycle detection and chain preview
- Agent forwarding toggle (pro)
- WiFi lock toggle with info dialog
- Theme picker, startup commands
- Port forwarding section delegated to
PortForwardSection.kt(bottom sheet, rows, add/edit/delete)
KeyManagerScreen
SSH key management:
- Generate Ed25519/RSA with optional passphrase
- Import from PEM file
- Copy public/private key (biometric-gated)
- Delete with confirmation
SettingsScreen
Global settings with sections: Appearance, Terminal (font size slider, scrollback, keep screen on, auto-reconnect, disconnect notify, WiFi lock), Keyboard (haptic, quick bar toggle, type, layout/colors), App Language, Security, About.
ConnectionListScreen — Kebab Menu
The TopAppBar actions include a kebab menu (MoreVert) with three items:
- Settings — navigates to SettingsScreen
- Export Vault — opens
VaultExportSheet(ModalBottomSheet) - Import Vault — opens
VaultImportSheet(ModalBottomSheet)
Vault Export / Import
File format — .swb (SSH Workbench Backup):
[4 bytes] magic: 0x53 0x57 0x42 0x31 ("SWB1")
[1 byte] mode: 0x01 = password, 0x02 = QR
[16 bytes] salt (Argon2id salt)
[12 bytes] nonce (AES-GCM nonce)
[N bytes] ciphertext (AES-256-GCM encrypted JSON)
[16 bytes] GCM authentication tag
Encrypted JSON payload: version, exported_at, hosts (SavedConnection), credentials (passwords), keys (SshKey + private PEM), snippets, port_forwards, settings (optional — SettingsData with string/boolean/int/float maps from TerminalPrefsKeys.EXPORTABLE_*_KEYS).
Export flow (VaultExportScreen → VaultExportViewModel):
- Full-screen Scaffold (navigated via NavGraph route
vaultExport) - Select items: hosts (with credentials toggle), SSH keys, snippets, settings (optional, unchecked by default)
- Choose protection mode: Password or QR Code
- Password mode: min 12 chars, uppercase + lowercase + digit + special char required. Argon2id derives 32-byte key from password + random salt
- QR mode: random 32-byte key shown as QR (Base64URL, generated on Dispatchers.Default with progress spinner), must Save or Share QR before export is enabled. Confirmation dialog reminds user the vault is useless without the QR
- AES-256-GCM encrypts JSON payload → SAF file picker saves
.swb - QR can be saved to Pictures or shared directly via WhatsApp/Telegram/etc.
Import flow (VaultImportSheet → VaultImportViewModel):
- Pick
.swbfile via SAF - Validate magic bytes, detect mode
- Password mode: prompt password → derive key → decrypt
- QR mode: scan QR via ZXing → derive key → decrypt
- Duplicate detection by name across all entity types
- Conflict resolution: Skip / Overwrite / Import as New (appends " (imported)" suffix)
- Re-links credentials and port forwards to newly inserted host IDs
- Restores settings to DataStore if present (overwrites current values in a single transaction)
Free tier import gate: Free users can only import local vault saves (MODE_LOCAL). Pro-exported vaults (MODE_PASSWORD/MODE_QR) require a Pro subscription. Checked in VaultImportViewModel.selectFile() after parsing the .swb header.
Serialization: VaultExportSerializer.kt using org.json.JSONObject (Android built-in). No external serialization dependency.
UI — ViewModels
All @HiltViewModel with viewModelScope. ViewModels that need localized strings inject StringResolver (avoids holding Context).
| ViewModel | Purpose |
|---|---|
ConnectionListViewModel |
Connection CRUD, quick-connect parsing, dev defaults |
EditConnectionViewModel |
Form state for connection editor, save/connect flow, port forward CRUD |
KeyManagerViewModel |
Key generation, import, delete. Lean API (no unused error/generating StateFlows) |
SettingsViewModel |
Settings StateFlows + setters. Keyboard/AQB settings exposed as keyboardSettings: StateFlow<KeyboardDisplaySettings> and aqbSettings: StateFlow<QuickBarDisplaySettings> (combined from individual DataStore flows via combine().stateIn()) |
VaultExportViewModel |
Item selection, mode choice, password/QR key, encryption + SAF write |
VaultImportViewModel |
File parsing, password/QR decryption, conflict resolution, DB import |
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) and CredentialStore.
Extracted managers (delegated from TerminalService):
SftpSessionManager— Standalone SFTP session lifecycle. OwnssftpSessions,sshSessions, andjumpSessionsmaps (each SFTP tab has its own SSH connection + optional jump chain).closeAll()called inonDestroy()PortForwardManager— Opens/closes SSH port forwards (local, remote, dynamic/SOCKS5). TakesContextparameter for localized error messagesStartupCommandRunner— Executes startup commands after connect with silence detectionSshConnectionHelper— Shared auth lookup (buildAuth), TOFU host key verification, andSSHSessionfactory (createSession). Eliminates duplication betweenconnectSSH,buildJumpChain, andopenSftpSession
SessionEntry — per-session state:
sessionId, savedConnectionId, sshSession, localShellSession, telnetSession,
screenBuffer, parser, host, port, username, password (CharArray, zeroed on disconnect),
jumpHostId, keyId, autoReconnectEnabled, wifiLockEnabled, recorder, networkCallback,
fontSizeSp (per-session zoom), customLabel (rename), customThemeName (theme override),
jumpHostSessions (per-session jump tunnel chain, cleaned up on disconnect),
onScreenUpdated, onBufferReplaced, onFontSizeRequest, onScrollRequest (callbacks set by TerminalPane)
External view callbacks: TerminalPane's AndroidView update block binds onFontSizeRequest/onScrollRequest so external code (e.g. HardwareActionHandler) can update the live TerminalSurfaceView from outside the Compose tree. Without this pattern, setting entry.fontSizeSp from a non-Compose context wouldn't propagate to the on-screen view.
Session lifecycle:
ensureStarted()— re-registers started component viastartForegroundService()(counteracts any previousstopSelf())connectSSH()/connectTelnet()/startLocalShell()— creates SessionEntry- Output collection coroutine:
session.output.collect { parser.process(bytes) } - State tracking coroutine:
session.state.collect { state -> ... } _activeSessionsupdates use atomic.update{}(read-modify-write safety)disconnectSession()— cleanup, zero password, release WiFi lockcheckStopSelf()— stop service when no active sessions (terminal + SFTP)
ensureStarted() invariant: Every session-creating method calls ensureStarted() before adding to the sessions map. This is critical because checkStopSelf() → stopSelf() kills the started component. Without re-registering it, the service is destroyed when the Activity unbinds in onStop(). onTaskRemoved() also calls ensureStarted() to survive swipe-from-recents.
Startup commands: Executed for both SSH and Telnet sessions after connect (via StartupCommandRunner).
Jump host chain — buildJumpChain(jumpHostId):
- Follow
jumpHostIdpointers through Room, collect hops - Reverse (outermost first)
- Each hop: create SSHSession, authenticate, get
underlyingClient - Return final
SSHClientas proxyClient for destination
Auto-reconnect — scheduleAutoReconnect(sessionId):
- 3 attempts with delays: 5s, 10s, 30s
- Network-aware: uses
ConnectivityManager.NetworkCallbackto wait for connectivity before counting attempts - Countdown shown as
Disconnected("Reconnecting in Ns...")or"Waiting for network..." - Resets attempt counter when network becomes available
- Reuses cached
entry.passwordandentry.keyId - Network callback cleaned up on disconnect/cancel via
unregisterNetworkCallback()
WiFi lock — per-session WifiManager.WifiLock (WIFI_MODE_FULL_HIGH_PERF):
- Acquired on
SessionState.ConnectedifwifiLockEnabled - Released on disconnect, error, and
onDestroy()safety net
WakeLock — single PowerManager.PARTIAL_WAKE_LOCK for all sessions:
- 4-hour safety timeout
- Released when last session disconnects
Disconnect info — dumpDisconnectInfo(entry, reason, isError):
- Writes colored banner to terminal buffer (yellow for disconnect, red for error)
- Classifies reason via localized strings: socket lost, disconnected by host, connection timed out, etc.
- Shows WiFi/mobile signal strength at disconnect time (localized status descriptions)
Navigation — Three-Layer Box
MainActivity.setContent {
Box(fillMaxSize) {
// Layer 1: Terminal + Tab Bar (visible when Pane.TERMINAL)
Column {
SessionTabBar(sessions, tabOrder, tabTypes, activeSessionId, labels, ...)
Box(weight=1) {
for (sid in tabOrder) {
key(sid) {
if (tabTypes[sid] == SFTP) SftpScreen(...)
else TerminalPane(sessionId, service, visible, state, prefs, ...)
}
}
}
}
// Layer 2: NavHost (AnimatedVisibility when Pane.NAV_HOST)
SshWorkbenchNavGraph(navController, ...)
}
}
Keyboard modes (in TerminalPane):
- Custom keyboard (CKB): TerminalKeyboard + QuickBarView visible below terminal, Android IME hidden
- System keyboard (AKB): Android IME manages itself, QuickBarView always visible with terminal keys
- CKB temporary hide: "Hide Keyboard" (gear long-press) sets
ckbHiddenCompose state (not a pref change). Tapping terminal firesonTapShowKeyboard→ resetsckbHidden. CKB input (viakeyboard.keyEvents) also scrolls terminal to bottom (IME path has its ownsnapToBottomOnInput)
Password auth flow: Server-driven — connection is always attempted first. On auth failure, password dialog shown.
SftpScreen (Compose)
Full SFTP file browser rendered as an independent tab in Layer 1 (not a NavHost route). Standalone — owns its own SSH connection via SftpSessionManager. Uses SAF for local file access. Amber-accented tab in SessionTabBar.
- Always composed (not conditionally removed) — preserves SAF launcher registrations across picker lifecycle
- File type icons (32dp, color-coded): APK=Android, images=Image, video=VideoFile, audio=AudioFile, archives=FolderZip, PDF=PictureAsPdf, code=Code, shell=Terminal, keys=VpnKey
- Multi-select: tap icon to select, batch download/delete (handles ALL selected entries), selection bar with count
- Hidden files hidden by default, toggle in overflow menu
- Path breadcrumbs, upload/download with progress, large file confirmation
- Long-press context menu: download (files), rename, delete, copy path
- ViewModel:
SftpViewModel(_allEntriesis@Volatilefor thread-safe access) - SFTP tabs are independent from SSH sessions (own tab ID via
generateSessionId()) - Keyboard and quick bar auto-hidden when SFTP tab is active (tab type check in MainActivity)
- Supports duplicate, rename, close, Connect to Terminal via tab overflow menu (no theme picker for SFTP tabs)
- Disconnected state: shows error message + Reconnect button (tab-level
tabErroror dead session detection); operation errors on dead sessions also route to Reconnect instead of Retry - Critical:
SftpSession.close()must NOT callSFTPClient.close()— it sends a channel EOF that kills the parent SSH transport ("Broken transport; encountered EOF"). Just null out the reference.
LocalShellSession
JNI-based PTY to /system/bin/sh via PtyNative:
nativeCreatePty(rows, cols)→[masterFd, childPid]nativeSetPtySize(fd, rows, cols)— TIOCSWINSZ ioctlnativeSignalChild(pid, signal)— SIGHUP on stop- Read loop on master fd →
SharedFlow<ByteArray>
TelnetSession
Raw TCP with IAC handling:
- IAC WILL/WONT/DO/DONT negotiation
- SGA (suppress-go-ahead) and NAWS (window size) sub-negotiation
- Terminal type response:
xterm-256color - IAC 0xFF escaping in user input
KeyboardSettingsDialog
Tabbed bottom sheet (Keyboard / Quick Bar):
- Keyboard tab: Portrait/landscape size (20-50%), page indicators, color presets + hex picker, haptic, hints, key repeat delay, long press delay
- Quick Bar tab: Position (top/above KB/below KB/vertical left/right), size, color presets + hex picker
- Preview widget showing phone layout
- Pro feature gate: free users see settings read-only with upgrade banner
Dialogs
TerminalDialogs — All dialogs use MaterialAlertDialogBuilder with tinted icons in title bar (setIcon()) and dark terminal theme styling:
showHostKey()— TOFU host key verification (new key: lock icon/PRIMARY; changed key: alert icon/WARNING)AuthPromptDialog— keyboard-interactive auth with dynamic prompts and "Remember password" checkbox. This is the single entry point for all server-driven password prompts — there is no pre-connect password dialog.showCleanExit()— session ended dialog (close or stay)showUrlActions()— open/copy detected URLshowSaveBuffer()— save terminal buffer to filebuildConnectDialog()— manual quick-connect with saved connections list
SnippetDialogs — snippet picker (search + tap/long-tap), save/edit snippet, inline create
KeyboardSettingsDialog — keyboard customization (tabbed)
Hardware Key Actions
HardwareActionHandler (ui/HardwareActionHandler.kt) — owns all volume key + shake gesture mapping. Configured via ActionsScreen.kt (Settings → Terminal → Hardware Key Actions).
Triggers: Volume Up, Volume Down, Shake. Volume keys support single + double press with configurable delay (200-500ms, default 300). Shake is single-only.
Available actions: disabled, close_session, font_up, font_down, next_session, prev_session, scroll_up, scroll_down, custom (raw key sequence).
Architecture:
- MainActivity delegates
onKeyDown/onKeyUpto the handler (~5 lines) - Handler caches all preference values via
lifecycleScope.launchcollectors (no DataStore reads on the main thread) - Pending coroutine jobs per key for delayed dispatch
- Sensor lifecycle: registered only when shake is enabled and Activity is resumed
- When single is "disabled" but double is enabled, the delayed coroutine calls
AudioManager.adjustStreamVolume()so volume keys still work (with the configured delay) - Action execution: font/scroll go through
SessionEntry.onFontSizeRequest/onScrollRequestcallbacks (set by TerminalPane); session switching usesMainViewModel.switchToTerminal(); close usesdisconnectSession() - Custom key sequences parsed via
textToMenuItemAction()fromQuickBarCustomizerScreen.kt(same syntax as QuickBar app shortcuts:[Ctrl]x,[Alt]x,[Esc],[F1]-[F12],0xHH,\n \r \t). Bytes written viaviewModel.writeToSession(activeId, bytes).
Logging
FileLogger
Thread-safe dual logger (Logcat + file). Redacts passwords/passphrases. Timestamps all entries.
"Copy Log" exports a ZIP (sshworkbench_log_<timestamp>.zip) containing:
sshworkbench_debug.txt— the app debug loglogcat.txt— last 5000 lines of system logcat (crash stacks, Room errors, etc.)recordings/*.bin— session byte stream recordings
SessionRecorder
Debug-only raw byte stream recorder per session. Saves to Downloads for regression test generation.
Module: lib-ssh
Wraps SSHJ into a clean Kotlin coroutines API. Knows nothing about UI or terminals.
SSHSession
Active SSH session.
Flows:
output: SharedFlow<ByteArray>— terminal data from remotestate: StateFlow<SessionState>— lifecycle (Idle → Connecting → Connected → Disconnected/Error)
Key methods:
connect(config: SSHConnectionConfig)— full connection sequence: socket → host key verify → authenticate → open PTY → start shellwrite(data)— async write to shell stdin (10s timeout, detects zombie connections)writeImmediate(data)— synchronous write for DSR/DA responsesresize(cols, rows)— PTY resizedisconnect()— graceful closedebugLogBuffer— synchronized compound operations (check-then-add) for thread safety
Key loading — delegated to SSHKeyLoader:
- Auto-detects format from PEM header: PKCS#8, OpenSSH v1, legacy PEM, PuTTY PPK
- Ed25519 PKCS#8 (OID 1.3.101.112): parsed via BC ASN.1, seed extracted,
net.i2p.crypto.eddsakeys built (SSHJ's wire encoder requires these, not BC key types) - RSA/ECDSA PKCS#8: delegated to SSHJ's
PKCS8KeyFile - OpenSSH v1:
OpenSSHKeyV1KeyFile, legacy PEM:OpenSSHKeyFile, PuTTY:PuTTYKeyFile
Auth cascade (authenticateAll):
publickeyviaSSHKeyLoader.load()if key configuredpasswordif provided- Keyboard-interactive — server-driven prompts via
AuthPromptCallback.onAuthPrompt→ returnsAuthPromptResult(responses, remember). Up to 3 attempts (client-sideMAX_PASSWORD_ATTEMPTS); retry shows "Incorrect password — please try again" as instruction. - Prompted-password fallback — if keyboard-interactive was rejected outright (server never invoked the provider) but
allowedMethodscontains "password", prompts user via the sameAuthPromptCallbackand callsclient.authPassword(). Up to 3 attempts. Handles servers that only advertise the "password" SSH method. - If all methods fail →
UserAuthException→ disconnect banner.
Remember password: AuthPromptResult.remember flag threaded through callback. On auth success, SSHSession.pendingRememberPassword is set. TerminalService reads it after session.connect() returns successfully and calls credentialStore.savePassword(). If auth fails, field is never consumed → wrong passwords are never persisted.
Connection health:
- Keepalive: 15s interval, 3 missed = 45s cutoff
- Read silence monitor: detects zombie TCP (write succeeds but no read)
- TCP probe:
sendUrgentData(0)for direct connections; tunneled connections (connectVia) have no raw socket — probe trustsclient.isConnectedand SSHJ's KeepAliveRunner - Transport
DisconnectListener: catches SSHJ reader thread crashes (e.g., WiFi off) - Jump session monitor: coroutine watches each jump session's
stateflow — if a jump tunnel dies, the dependent session is force-disconnected immediately (SSHJ'sDirectConnectionchannel doesn't propagate errors to the inner transport)
SSHConnectionConfig
data class SSHConnectionConfig(
host, port, username, auth: SSHAuth,
ptyConfig: PtyConfig,
proxyClient: SSHClient? = null, // ProxyJump tunnel
tunnelOnly: Boolean = false // Auth-only, no shell
)
SSHAuth (sealed class)
Password(password)— redacts intoString()KeyFile(path, passphrase?)KeyString(key, passphrase?)— PEM content in memoryNone— will prompt via keyboard-interactive or prompted-password fallback
AuthPromptResult (data class)
responses: List<String>— user's answers (one per prompt)remember: Boolean— whether user ticked "Remember password"
SessionState (sealed class)
Idle— not connectedConnecting(host, port)— connection in progressConnected— shell readyDisconnected(reason, cleanExit)—cleanExit=trueforexit/logout/Ctrl+DError(message, cause)— connection/auth error
SftpSession
SFTP file operations wrapping SSHJ's SFTPClient with a coroutines API. sftpClient field is @Volatile for safe cross-thread visibility. All operations serialized on a single-threaded dispatcher (SFTPClient is not thread-safe).
Key methods:
connect(sshClient)— opens SFTP subsystem viaSSHClient.newSFTPClient()listDirectory(path, sortOrder)→List<SftpEntry>— folders first, sorted by name/size/datedownloadFile(remotePath, outputStream, onProgress)— streaming download with progressuploadFile(inputStream, remotePath, size, onProgress)— streaming upload with progresscreateDirectory(path),rename(old, new),delete(path, isDirectory)canonicalize(path)— resolve home directory
SftpEntry: name, path, isDirectory, size, modifiedTime, permissions (Unix string like drwxr-xr-x)
Standalone SFTP: Each SFTP session owns its own SSH connection (tunnelOnly=true — auth + keepalive + zombie monitor, no PTY/shell). TerminalService.openSftpSession(connectionId) looks up the connection from Room, builds auth via SshConnectionHelper, builds jump chain via buildJumpChain (ProxyJump support), creates an SSHSession, connects, then opens the SFTP subsystem. SftpSessionManager tracks SSH, SFTP, and jump sessions per tab; closeAll() called in onDestroy(). SFTP tabs are independent from terminal sessions — disconnecting terminal SSH does not affect SFTP. MainViewModel monitors each SFTP tab's SSH session state — on disconnect/error, updates SftpTabInfo to show the disconnect UI with Reconnect button.
HostKeyVerifyCallback
TOFU interface: verifyHostKey(host, port, keyType, fingerprint) → HostKeyAction.ACCEPT/REJECT
Module: lib-terminal-view
Our own VT100/VT220/xterm parser + Canvas renderer. No external terminal library.
Engine
ScreenBuffer
2D character grid with scrollback history.
- Dimensions:
rows,cols,maxScrollback(default 10000, max 65000) - Storage:
Array<ScreenRow>(visible) +ArrayDeque<ScreenRow>(history) - Attributes: Packed 64-bit
TextAttrper cell (bold, italic, underline, blink, reverse, 256-color, RGB) - Modes: wrap, insert, origin, reverse screen, bracketed paste, mouse tracking, vt52Mode (DECANM)
- Scroll region:
scrollTop/scrollBottomfor partial screen scrolling - Alternate screen: DECSET 47/1047/1049 (vim, htop use this)
Key operations: cursor movement (CUU/CUD/CUF/CUB/CUP/HVP), scroll (SU/SD/IND/RI), erase (ED/EL/ECH), insert/delete (ICH/DCH/IL/DL), tab stops, charset designation (G0/G1), SGR attributes.
reflowResize(newRows, newCols) — creates new buffer, reflows wrapped lines.
truncateHistory(newMax) — synchronized, trims oldest scrollback lines to newMax, clamps scrollOffset. Used when the user lowers the global scrollback setting while sessions are active.
Parser Hierarchy
BaseTermParser → Vt220Parser → XtermParser (ANSI mode — inheritance chain)
Vt52Parser (VT52 mode — standalone delegate)
BaseTermParser: C0/C1 controls, CSI dispatch (~30 commands), SGR (0-108), SM/RM modes, DSR, character set designation. Delegates to Vt52Parser when screen.vt52Mode is true.
Vt220Parser: DECSCA (selective erase), DHDW (double-height/double-width lines), G2/G3 charsets, locking/single shifts.
XtermParser: OSC (title, clipboard), DCS, bracketed paste, mouse reporting (X10/SGR/URXVT), alternate screen.
Vt52Parser: VT52 compatibility mode. Cursor movement (ESC A-D), direct addressing (ESC Y row col), erase (ESC J/K), graphics charset (ESC F/G with DEC Special Graphics line-drawing glyphs), DECID response (ESC / Z). Entered via CSI ?2l (DECANM), exited via ESC <.
See
docs/TERMINAL_PARSER.mdfor the full parser architecture guide, state machine details, DECCOLM mid-chunk fix, VT52 implementation, graphics charset mapping table, and known quirks.
TextAttr (64-bit packed)
Bits 0-7: Flags (bold, italic, underline, blink, reverse, hidden, erasable, dhdw)
Bit 8: DHDW bottom half
Bits 9-12: FG/BG color mode (default, palette-16, 256-index, RGB)
Bits 13-36: FG color (24-bit)
Bits 37-60: BG color (24-bit)
withFg()/withBg() use 0xFFFFFF (24-bit) masks for correct color extraction and insertion.
Renderer
TerminalSurfaceView
SurfaceView with dedicated render thread.
Clipboard caching: Clipboard state cached on UI thread via refreshClipboardCache() into @Volatile fields (cachedHasClipboard, cachedClipboardText). Avoids cross-thread ClipboardManager access from render thread.
Configurable i18n strings: copiedOneLineText, copiedLinesFormat, newOutputFormat — settable properties with English defaults. setToolbarLabels(copy, paste, selectAll, search) delegates to SelectionToolbar.setLabels(). Set by TerminalPane with localized strings from resources.
Render thread (RenderThread):
- Waits on
lock.wait()— 16ms during animation, 530ms during cursor blink, 500ms when idle - Wakes on:
requestRender(),dirtyflag, fling active, edge effects, cursor blink toggle - Draws via
TerminalRenderer.render()+ overlays (scrollbar, jump buttons, dimension overlay, floating toolbar, edge effects)
Cursor blink: cursorBlinkEnabled property. When on, cursor toggles every 530ms. Stops blinking after 15s of inactivity (cursor goes solid). Resumes on input (notifyInput()) or new screen data. Blink state passed to renderer via cursorBlinkOn parameter.
Custom typeface: setTypeface(Typeface) propagates to renderer, triggers font metrics recalculation and dimension check.
Gesture handling (TerminalGestureHandler):
- Pinch-to-zoom (font size)
- Vertical scroll (scrollback history)
- Horizontal scroll (wide lines when word-wrap off)
- Tap to show keyboard / URL detection.
onTapShowKeyboardcallback fires when tapped withsoftInputEnabled=false(CKB mode) - Long-press text selection with handles
- Fling with velocity-based scrolling
Text selection (TerminalTextSelection):
- Start/end handle drag
- Floating toolbar via
SelectionToolbar:ButtonIdenum (COPY, PASTE, SELECT_ALL, SEARCH), configurable labels viasetLabels(),updateTextSize()updates all paint sizes - Selection coordinates in buffer space
- Snap-to-content on drop: when a handle is dropped on trailing whitespace, it snaps to the nearest content.
snapStartToContent()snaps forward,snapEndToContent()snaps backward. UsesfirstNonSpaceCol()/lastNonSpaceCol()for whitespace detection (handles all whitespace types, not just space). Snapping only occurs onACTION_UP, not during drag.
Mouse reporting (MouseReporter):
- Only taps are forwarded as mouse clicks when mouse mode is active
- Scroll, long-press (select), double-tap, and fling always bypass mouse mode — scrollback and text selection always work
- SGR, URXVT, and X10 coordinate encoding
- Extracted to
MouseReporter.kt—sendClick()sends press+release at pixel coordinates
URL detection (TerminalUrlDetector):
- Regex matching on visible viewport
- Underline rendering
- Tap → open/copy dialog
TerminalRenderer
Stateless Canvas drawing. Handles: character grid, cursor (block/underline/bar with blink support via cursorBlinkOn parameter), 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. Configurable typeface via setTypeface(Typeface).
TerminalTheme
Terminal color scheme: fg, bg, cursor, 16 ANSI colors. 20 built-in themes: Default Dark, Ayu Dark, Catppuccin Mocha, Dracula, Everforest Dark, Gruvbox, Kanagawa, Material Dark, Monokai, Nightfox, Nord, Nord Warm, One Dark, Palenight, Rosé Pine, Solarized Dark, Solarized Dark Soft, Solarized Light, Solarized Light Soft, Tokyo Night. Resolved by name via builtInThemes.find { it.name == name }.
Module: lib-terminal-keyboard
Custom Canvas-based keyboard. JSON-driven layouts, language packs.
Architecture
TerminalKeyboard (entry point, Builder)
├── KeyboardView (ViewPager2, infinite circular pages, onViewRecycled cleanup)
│ └── KeyboardPageView (Canvas, per-page key rendering + hit testing)
├── QuickBarView (Canvas, infinite scroll or static)
├── HintPopupView (key label tooltip)
├── LongPressPopupView (accent/variant selector)
└── 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 which is still unfixed in ViewPager2 1.1.0 for non-FragmentStateAdapter usage.
Data Models
KeyboardLayout — parsed from JSON:
{
"pages": [
{ "id": "alpha", "rows": [ { "keys": [...] } ] },
{ "id": "terminal", ... },
{ "id": "symbols", ... },
{ "id": "fkeys", ... }
],
"quickBar": { "keys": [...], "height": 42 },
"settings": { "showHints": true, ... }
}
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), 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.
Touch Handling
- ACTION_DOWN: Find key at touch coordinates, show hint popup (all keys, no label length filter), start long-press timer or key repeat
- ACTION_MOVE: Track long-press popup variant selection. Cancel long-press if finger moves off key
- ACTION_UP: Emit key event (or selected long-press variant), dismiss popups
- Hint positioning: smart — above key by default, below if off screen top, horizontally clamped to screen edges
- Long-press popup: auto-shrinks cell width when total popup width exceeds screen (e.g., Swedish
awith 9 variants)
- Modifiers: Tap = arm (next key), double-tap/long-press = lock (caps lock)
- Long-press gear (⚙ on space): triggers
onSettingsTapcallback
Modifier State Machine
IDLE ──tap──► ARMED ──tap──► LOCKED
▲ │ │
└──consume───┘ tap───────┘
consumeArmedModifiers() called after every non-modifier key press (consumes all armed modifiers at once, no per-modifier parameter).
Language Packs
JSON files mapping key IDs to localized characters:
{ "q": { "primary": "q", "shift": "Q", "long": ["q", "Q"] } }
Languages: English (lang_en.json), Spanish (lang_es.json), Swedish (lang_sv.json).
Themes
KeyboardTheme: keyboardBg, keyBg, keyBgPressed, keyBorder, keyText, keyHintText, styles (per-style overrides: danger, modifier, confirm, nav, fn, special, menu).
Built-in: Dark Terminal, Light, Monokai, Solarized Dark.
Quick Bar
QuickBarView: Canvas-based, supports horizontal (infinite scroll with fling) and vertical orientation. Draggable grip handle. Snippets button integration.
- Menu keys: vim, nano, tmux, screen — QB keys with
menuItems(list ofMenuItem(label, action)). Tap showsLongPressPopupViewin menu mode with text-sized cells and full-screen touch overlay.onMenuKeyTapcallback routes toTerminalKeyboard.showMenuPopup(). Styled with"menu"theme style (purple accent) - Active modifier highlighting:
activeModifiersset tracks ARMED/LOCKED modifier key IDs, drawn with green highlight color - Key repeat on quick bar: arrows and PGUP/PGDN support auto-repeat via
KeyRepeatHandler. Scroll transition cancels repeat (onKeyUpfires whenisScrollingbecomes true) - Double-tap word jump: double-tapping left/right arrow sends ESC b / ESC f (word back/forward). Uses slow repeat (350ms delay, 300ms interval) on hold after double-tap
- Hints on all keys:
onKeyDownfires on ACTION_DOWN for all QB keys (not just repeatable), so hints show for ESC, TAB, CTRL etc. — not just arrows - System keyboard quick bar: separate key set (
systemKeyboardQuickBarKeys()) with CTRL, ESC, TAB,:,/, arrows, HOME, END, PGUP, PGDN, F1-F12. Uses minimum key width (36dp) based on smallest key weight for proper sizing - Quick bar keys override:
setQuickBarKeys()stores override sosetTheme()doesn't reset custom keys - CTRL modifier for system keyboard:
consumeArmedModifiers()allows external input paths to consume all ARMED modifier states - QB Customizer (pro):
QuickBarCustomizerScreen.kt— full-screen Compose dialog with two tabs (Keys, App Shortcuts). Keys tab: drag-and-drop reorder (drag handle), delete (trashcan), available keys in 4-column grid with tap-to-add. App Shortcuts tab: drag-and-drop reorder apps (auto-collapses on drag), expand to edit individual key maps with drag-and-drop reorder, add/remove apps and keys. Both tabs useModifier.draggablewith swap-on-half-height-threshold,rememberUpdatedStatefor stable drag callbacks, animated elevation on dragged items.QuickBarCustomizer.ktprovidesQuickBarKeyPool(27 keys), serialization (serializeKeyIds/deserializeKeyIds,serializeAppShortcuts/deserializeAppShortcuts), andresolveKeys(). CQB and AQB have independent custom configs via DataStore prefs (cqb_custom_keys,cqb_custom_apps,aqb_custom_keys,aqb_custom_apps). Accessed from KB Settings → Quick Bar tab → Customize, or AQB Settings → Customize.TerminalKeyboard.setAppShortcuts()/getQuickBarKeys()/getAppShortcuts()for runtime overrides.
Key Repeat
KeyRepeatHandler: supports two overloads:
start(onRepeat)— default timing (from keyboard settings)start(delayMs, intervalMs, onRepeat)— custom timing (used for slow word-jump repeat)
Acceleration: after 5 repeats, interval halves (2x speed). After 15 repeats, interval quarters (4x speed). Minimum interval capped at 10ms. Configurable via Settings → Terminal → Cursor speed (Slow/Normal/Fast/Rapid).
Threading Model
| Thread | Purpose |
|---|---|
| Main (UI) | Compose/View rendering, dialog callbacks |
serviceScope (Main) |
TerminalService coroutines (state updates, notifications) |
Dispatchers.IO |
SSH connect, Room queries, file I/O, credential store |
RenderThread (daemon) |
SurfaceView Canvas drawing (~60fps active, ~2fps idle) |
sshj-Reader-* |
SSHJ transport reader (per connection) |
sshj-KeepAlive |
SSHJ keepalive sender |
writeExecutor |
SSH write queue (single thread) |
| JNI PTY reader | Local shell output reader |
Synchronization:
ScreenBuffer:@Synchronizedon mutation methods,@Volatiledirty flagSessionEntry.parserLock: Object lock for parser.process() callsRenderThread.lock: Wait/notify for render requestsConcurrentHashMapforsessionsmap in TerminalService_activeSessions: AtomicMutableStateFlow.update{}for read-modify-write safetySSHSession.debugLogBuffer:synchronizedblock for compound check-then-add operationsSSHSession.stderrCaptureLock: Companion lock serializingSystem.setErr/System.setPropertyacross concurrent connections (SSHJ trace capture)SftpSession.sftpClient:@Volatilefor cross-thread visibilitySftpViewModel._allEntries:@Volatilefor thread-safe access
Per-Session State Persistence
Session customization (zoom, rename, theme) has a three-tier storage model:
| Tier | Storage | Lifetime | Used for |
|---|---|---|---|
| View | TerminalSurfaceView.fontSizeSp |
View instance | Live zoom level during session |
| Service | SessionEntry.fontSizeSp/customLabel/customThemeName |
Foreground service | Survives Activity destruction, view recreation |
| Database | SavedConnection.fontSizeSp |
Forever | Host's preferred zoom across app restarts |
Font size (zoom) flow
- Pinch zoom → saves to
SessionEntry.fontSizeSp(always) - Single session for host → also saves to Room
SavedConnection.fontSizeSp - Multiple sessions for same host → only
SessionEntry(per-session, ephemeral) - New connection → reads
SavedConnection.fontSizeSpfrom Room → seedsSessionEntry - Duplicate session → copies
fontSizeSpfrom sourceSessionEntry - View recreation (Compose rebuilds
AndroidView) → factory reads fromSessionEntry
Why views get recreated
TerminalSurfaceView is wrapped in Compose AndroidView. The factory runs once per composition entry. However, changes to activeSessions (adding/removing sessions) can cause Compose to rebuild the slot table, destroying and recreating all AndroidView instances. key(sid) and INVISIBLE mitigate but don't fully prevent this. The SessionEntry persistence ensures state survives regardless.
Rename / Theme
Same pattern: saved to SessionEntry.customLabel/customThemeName on user action. Restored in the session observer's label/theme resolution when the ViewModel loads sessions on service rebind.
Security
Credential Storage
- Passwords: Stored in
EncryptedSharedPreferences(AES-256-GCM master key via Android Keystore). Key format:password_$connectionId. Passwords are only saved when the user explicitly checks "Remember password" in the auth dialog or sets one in the connection editor.deletePassword()is called when cleared. - SSH private keys: PEM stored in
CredentialStoreunder keykey_private_$keyId. Never stored in Room database or as plaintext files. Passphrase-protected keys supported. - In-memory: Passwords held as
CharArrayinSessionEntry, zeroed (fill('\u0000')) on session disconnect and inonDestroy().
Host Key Verification (TOFU)
- First connection: user sees fingerprint, key type, and server address. Must explicitly Accept or Reject.
- Subsequent connections: stored fingerprint compared. On mismatch: WARNING dialog with both old and new fingerprints, colored red/orange.
- Fingerprints stored in
CredentialStoreunder keyhostkey_$host:$port. - No automatic acceptance — every host key change requires user confirmation.
- Default REJECT: When no UI handler is available (e.g., service started but Activity not yet bound), first-seen host keys are REJECTED, not silently accepted. This prevents MITM during the service-start window.
Screen Capture Prevention
FLAG_SECUREapplied viapreventScreenCapturepreference (default ON)- Toggleable in Settings → Security → "Prevent Screen Capture"
- Prevents: task switcher thumbnails, screenshots, screen recordings, Miracast display of terminal content
- Applied dynamically in
MainActivity.onCreate()vialifecycleScope.collect
Clipboard Security
- All
setPrimaryClipcalls setClipDescription.EXTRA_IS_SENSITIVE(Android 13+) to prevent keyboard clipboard preview - Applies to: terminal text copy, SSH private key copy, SFTP path copy
- Clip labels set to empty string to avoid app identification
Paste Security
- Bracketed paste mode (DECSET 2004) sanitizes clipboard content before sending
- Strips
\x1b[200~and\x1b[201~sequences from pasted text to prevent paste-escape injection attacks (CVE class) - Non-bracketed paste passes content unmodified (standard behavior)
URL Detection Security
- Only
http://,https://,ftp://schemes detected —file://excluded - Prevents malicious servers from displaying
file:///data/...URLs to trick users into opening local files
Biometric Authentication
BiometricAuthManagerusesBiometricPromptwithBIOMETRIC_STRONG | DEVICE_CREDENTIAL- 5-minute session timeout — re-auth required after expiry
- Guards: app unlock, password reveal in connection editor, private key export
isAuthExpired()checks elapsed time since last successful auth
SSH Authentication Cascade
Server-driven: the TCP connection and host key verification always happen first. Only after the server actually requests authentication is any password dialog shown to the user. If no credentials are stored, buildAuth returns SSHAuth.None and the cascade falls straight through to keyboard-interactive, which prompts via AuthPromptCallback when (and only when) the server asks.
- Public key (if SSH key configured) —
KeyStringwith PEM from CredentialStore - Password (if stored or provided in connection editor) — via
SSHAuth.Password - Keyboard-interactive — server-driven prompts via
AuthPromptCallback, dialog shown to user with "Remember password" option. Skipped entirely when no callback is set (no empty-password fallback). - If all methods fail →
UserAuthException→ disconnect banner shown in terminal, user can Reconnect.
Logging & Redaction
FileLoggerautomatically redacts any string containing "password" or "passphrase" in log output- Debug log file written to
Downloads/SshWorkbench/sshworkbench_debug.txt SessionRecorder(debug builds only) records raw byte streams — not enabled in release
Network Security
network_security_config.xml: cleartext disabled, trust restricted to system CAs only- WiFi lock:
WIFI_MODE_FULL_HIGH_PERFper-session, released on disconnect, error, andonDestroy()safety net - SSH keepalive: 15s interval, 3 missed = 45s timeout. Zombie detection via read-silence monitor + TCP probe
- ADB broadcast receiver:
RECEIVER_EXPORTEDonly in debug builds (DEV_DEFAULTS),RECEIVER_NOT_EXPORTEDin release
Native Code Hardening
- Both native modules (vault-crypto, pty_jni) compiled with
-fstack-protector-strong -D_FORTIFY_SOURCE=2 -Wformat -Wformat-security - VaultCrypto JNI methods protected by ProGuard keep rule (prevents R8 stripping in release)
Pro Migration Security
checkProMigration()verifies the detected old pro APK's signing certificate matches the current app's signing key (SHA-256 digest comparison)- Prevents spoofing via fake APK with the pro package name
Firebase
google-services.jsonin.gitignore— never committed to repository- Analytics + Crashlytics only — no remote config or database
Firebase
Firebase Analytics and Crashlytics are integrated via the Firebase BoM.
- Gradle:
firebase-bom(platform),firebase-analytics,firebase-crashlyticsdependencies inapp/build.gradle.kts. Plugins:com.google.gms.google-servicesandcom.google.firebase.crashlyticsin root and app. - Config:
google-services.jsonatapp/— never read or committed (in.gitignore) - Version catalog:
gradle/libs.versions.tomldefinesfirebaseBom,googleServices,firebaseCrashlyticsPlugin
Permissions
| Permission | Purpose |
|---|---|
INTERNET |
SSH/Telnet connections |
ACCESS_NETWORK_STATE |
Network-aware auto-reconnect |
FOREGROUND_SERVICE |
TerminalService |
WAKE_LOCK |
Keep CPU active during sessions |
ACCESS_WIFI_STATE / CHANGE_WIFI_STATE |
WiFi lock |
Launcher Icons
Flavor-specific adaptive icons with per-flavor color schemes:
| Flavor | Background | W color | >_ color |
|---|---|---|---|
| Free | #1a2f2f (dark teal) |
#5cc0c0 |
#50e8d8 |
| Pro | #2a1f0a (dark amber) |
#c08028 |
#ffb020 |
File structure:
app/src/{free,pro}/res/
├── drawable/ic_launcher_background.xml ← solid color vector (108dp)
├── mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/
│ ├── ic_launcher.png ← legacy square icon
│ ├── ic_launcher_round.png ← legacy round icon
│ └── ic_launcher_foreground.png ← (xxxhdpi only, 432px)
└── mipmap-anydpi-v26/
├── ic_launcher.xml ← adaptive icon
└── ic_launcher_round.xml ← adaptive icon (round)
Design: large W (monospace bold) filling and bleeding icon edges, >_ prompt overlay centered on top. Rendered from SVG via cairosvg.
ADB Testing
Debug builds register a broadcast receiver for automated terminal input testing.
- Action:
com.roundingmobile.sshworkbench.INPUT - Extras:
text(String),enter(Boolean),esc(String),bytes(hex String),log(Boolean) - Cursor logging:
logCursorPos()writes row, col, and line preview with cursor marker (|) to FileLogger - Security:
RECEIVER_EXPORTEDonly whenDEV_DEFAULTSis true (debug builds)
See docs/TESTING.md for full API reference, examples, and test script usage.
String Resources
All user-visible text uses strings.xml with EN, ES, SV translations. No hardcoded strings except universal technical terms (SSH, WiFi, Internet). Resource files:
app/src/main/res/values/strings.xml(English)app/src/main/res/values-es/strings.xml(Spanish)app/src/main/res/values-sv/strings.xml(Swedish)
Localized areas include: SelectionToolbar labels (copy/paste/select all/search), disconnect reasons, network status descriptions, disconnect banners, port forward error messages, copied-lines notifications, and new-output indicators. Library views (TerminalSurfaceView, SelectionToolbar) accept i18n strings via setter methods — the app module provides localized values from string resources.