Password auth dialog, credential fixes, dialog icons, Firebase, ADB testing

- Password auth: server-driven flow (try connect first, prompt on auth failure),
  IME Done key triggers Connect, masked by default, keyboard focus/show
- Credential fixes: deletePassword when cleared in edit screen, no auto-save
  of password in autoSaveConnection (only explicit "Remember" saves)
- Dialog icons: moved from centered body to title bar (setIcon) across all dialogs
- Keyboard: defer terminalView focus until Connected state, show AKB explicitly
  after auth retry succeeds
- Password dialog: ADJUST_PAN soft input mode, requestFocus + showSoftInput
- Firebase Analytics + Crashlytics integration (BoM, plugins, .gitignore)
- ADB testing: broadcast receiver enhancements, test script, docs/TESTING.md
- Quick bar: double-tap word jump, CTRL modifier for system keyboard,
  key repeat on quick bar, green highlight for active modifiers
- Snippet hint colors lightened (#BBBBBB)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-03-28 13:06:39 +01:00
parent 34fd76e48d
commit 73e7629a5a
13 changed files with 538 additions and 53 deletions

5
.gitignore vendored
View file

@ -48,6 +48,11 @@ proguard/
*.jks
*.keystore
# =========================
# Firebase / Google Services
# =========================
google-services.json
# =========================
# OS junk
# =========================

View file

@ -7,6 +7,8 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt.android)
alias(libs.plugins.google.services)
alias(libs.plugins.firebase.crashlytics.plugin)
}
val keystoreProps = Properties().apply {
@ -188,6 +190,11 @@ dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Firebase
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)

View file

@ -87,7 +87,7 @@ object SnippetDialogs {
}
val contentEdit = EditText(context).apply {
hint = "Command..."
setHintTextColor(Color.parseColor("#666666"))
setHintTextColor(Color.parseColor("#BBBBBB"))
setTextColor(Color.WHITE)
textSize = 14f
typeface = Typeface.MONOSPACE
@ -100,7 +100,7 @@ object SnippetDialogs {
))
val nameEdit = EditText(context).apply {
hint = "Name (optional)"
setHintTextColor(Color.parseColor("#666666"))
setHintTextColor(Color.parseColor("#BBBBBB"))
setTextColor(Color.WHITE)
textSize = 13f
setBackgroundColor(Color.parseColor("#1E1E2E"))
@ -179,7 +179,7 @@ object SnippetDialogs {
// Search bar
val searchInput = EditText(context).apply {
hint = "Search snippets..."
setHintTextColor(Color.parseColor("#666666"))
setHintTextColor(Color.parseColor("#BBBBBB"))
setTextColor(Color.WHITE)
textSize = 14f
setBackgroundColor(Color.parseColor("#2A2A3E"))
@ -270,7 +270,7 @@ object SnippetDialogs {
emptyText = if (snippets.isEmpty()) {
TextView(context).apply {
text = "No snippets yet. Tap + to create one."
setTextColor(Color.parseColor("#666666"))
setTextColor(Color.parseColor("#BBBBBB"))
textSize = 14f
gravity = Gravity.CENTER
setPadding(0, (32 * dp).toInt(), 0, 0)

View file

@ -42,6 +42,8 @@ import com.roundingmobile.sshworkbench.data.local.SavedConnectionDao
import com.roundingmobile.sshworkbench.data.terminalDataStore
import com.roundingmobile.sshworkbench.util.FileLogger
import com.roundingmobile.keyboard.TerminalKeyboard
import com.roundingmobile.keyboard.model.KeyAction
import com.roundingmobile.keyboard.model.KeyDefinition
import com.roundingmobile.keyboard.model.KeyEvent as KbKeyEvent
import com.roundingmobile.keyboard.model.toBytes
import com.roundingmobile.keyboard.theme.BuiltInThemes
@ -227,9 +229,9 @@ class TerminalActivity : AppCompatActivity() {
// System keyboard — hide custom keyboard, enable IME
keyboardContainer.visibility = View.GONE
terminalView.softInputEnabled = true
// Set up custom keyboard just for the quick bar
// Set up keyboard just for the quick bar — hidden until AKB keys are applied
quickBarContainer.visibility = View.GONE
setupCustomKeyboard()
quickBarContainer.visibility = View.VISIBLE
} else {
// Custom keyboard — suppress Android IME
setupCustomKeyboard()
@ -282,8 +284,8 @@ class TerminalActivity : AppCompatActivity() {
val bottom = maxOf(systemInsets.bottom, imeInsets.bottom)
view.setPadding(0, systemInsets.top, 0, bottom)
if (terminalView.softInputEnabled) {
// System keyboard mode: quick bar always visible
quickBarContainer.visibility = View.VISIBLE
// System keyboard mode: quick bar shows/hides with the system keyboard
quickBarContainer.visibility = if (imeVisible) View.VISIBLE else View.GONE
}
FileLogger.log(TAG, "Insets: top=${systemInsets.top} bottom=$bottom ime=$imeVisible")
WindowInsetsCompat.CONSUMED
@ -356,9 +358,10 @@ class TerminalActivity : AppCompatActivity() {
}
}
// Input routing — IME input goes directly to the session
// Input routing — IME input, applying quick bar CTRL modifier if active
terminalView.onTerminalInput = { bytes ->
terminalService?.writeInput(sessionId, transformCursorKeys(bytes))
val transformed = applyQuickBarCtrl(transformCursorKeys(bytes))
terminalService?.writeInput(sessionId, transformed)
}
// URL tap handler — show Open/Copy dialog
@ -395,7 +398,8 @@ class TerminalActivity : AppCompatActivity() {
}
}
terminalView.requestFocus()
// Don't requestFocus here — wait until Connected state
// (in system keyboard mode, focusing the terminal opens the AKB prematurely)
// SECURITY NOTE: DEV-ONLY broadcast receiver — EXPORTED only when DEV_DEFAULTS is true (debug builds)
// Allows ADB input via `adb shell am broadcast`; NOT_EXPORTED in release builds
@ -403,13 +407,30 @@ class TerminalActivity : AppCompatActivity() {
override fun onReceive(context: Context, intent: Intent) {
val text = intent.getStringExtra("text")
if (text != null) {
FileLogger.log(TAG, "[session=$sessionId] DEBUG inputReceiver: text='$text'")
logCursorPos("before-text '$text'")
terminalService?.writeInput(sessionId, text.toByteArray(Charsets.UTF_8))
}
if (intent.getBooleanExtra("enter", false)) {
FileLogger.log(TAG, "[session=$sessionId] DEBUG inputReceiver: enter")
logCursorPos("before-enter")
terminalService?.writeInput(sessionId, byteArrayOf(0x0D))
}
// Send raw escape sequence: --es esc "[D" sends ESC [D (left arrow)
val esc = intent.getStringExtra("esc")
if (esc != null) {
logCursorPos("before-esc '$esc'")
terminalService?.writeInput(sessionId, "\u001b${esc}".toByteArray(Charsets.UTF_8))
}
// Send raw bytes: --es bytes "1b 62" sends ESC b (word back)
val bytesHex = intent.getStringExtra("bytes")
if (bytesHex != null) {
val bytes = bytesHex.trim().split(" ").map { it.toInt(16).toByte() }.toByteArray()
logCursorPos("before-bytes '${bytesHex}'")
terminalService?.writeInput(sessionId, bytes)
}
// Log cursor after a short delay to capture server response
if (intent.getBooleanExtra("log", false)) {
terminalView.postDelayed({ logCursorPos("query") }, 100)
}
}
}
val receiverFlags = if (BuildConfig.DEV_DEFAULTS) Context.RECEIVER_EXPORTED
@ -527,6 +548,11 @@ class TerminalActivity : AppCompatActivity() {
)
applyKeyboardSettings(globalSettings)
}
// In system keyboard mode, swap quick bar keys after settings are applied
// (setTheme inside applyKeyboardSettings resets keys from the JSON layout)
if (terminalView.softInputEnabled) {
terminalKeyboard?.setQuickBarKeys(systemKeyboardQuickBarKeys())
}
}
FileLogger.log(TAG, "[session=$sessionId] Custom keyboard attached")
}
@ -673,13 +699,16 @@ class TerminalActivity : AppCompatActivity() {
if (inDisconnectedMode) return
}
is SessionState.Connecting -> {
// Write connecting status into terminal buffer
writeStatusToTerminal("\u001b[1;36m── Connecting to ${state.host}:${state.port}... ──\u001b[0m")
}
is SessionState.Connected -> {
if (previousState is SessionState.Connecting) {
// Blank line to separate "Connecting to..." from session output
writeStatusToTerminal("")
// Send current terminal dimensions — the PTY was allocated with
// the initial surface size, but dimensions may have changed while
// connecting (keyboard appeared, layout settled)
terminalService?.resizePty(sessionId, surfaceCols, surfaceRows)
}
// Reset disconnected mode state if reconnecting
if (inDisconnectedMode) {
@ -695,6 +724,13 @@ class TerminalActivity : AppCompatActivity() {
}
cleanExitDialogShown = false
terminalView.requestFocus()
// In system keyboard mode, explicitly show the soft keyboard
if (terminalView.softInputEnabled) {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
terminalView.post {
imm.showSoftInput(terminalView, InputMethodManager.SHOW_IMPLICIT)
}
}
// Persist session recovery state so we can recover after service kill
val entry = terminalService?.getSession(sessionId)
if (entry?.host != null) {
@ -750,7 +786,17 @@ class TerminalActivity : AppCompatActivity() {
}
is SessionState.Error -> {
recordSessionEnd()
showDisconnectedStatus("Error: ${state.message}")
val msg = state.message.lowercase()
if (msg.contains("auth") || msg.contains("password")) {
// Auth failure — prompt for password retry
val entry = terminalService?.getSession(sessionId)
val host = entry?.host ?: intent.getStringExtra(EXTRA_HOST) ?: ""
val user = entry?.username ?: intent.getStringExtra(EXTRA_USERNAME) ?: ""
val port = entry?.port ?: intent.getIntExtra(EXTRA_PORT, 22)
showPasswordPrompt(host, port, user, state.message)
} else {
showDisconnectedStatus("Error: ${state.message}")
}
}
}
}
@ -969,6 +1015,101 @@ class TerminalActivity : AppCompatActivity() {
* When application cursor keys mode (DECCKM) is active, transform
* ESC [ A/B/C/D/H/F to ESC O A/B/C/D/H/F for vim compatibility.
*/
/**
* Apply the quick bar's CTRL modifier to input from the system keyboard.
* Converts printable characters to control characters (Ctrl+A=0x01, etc.)
* and consumes the ARMED state (single-shot).
*/
private fun showPasswordPrompt(host: String, port: Int, username: String, errorMessage: String? = null) {
TerminalDialogs.showPasswordDialog(
context = this,
host = "$host:$port",
username = username,
errorMessage = errorMessage,
onConnect = { result ->
if (result.remember && savedConnectionId > 0) {
lifecycleScope.launch(Dispatchers.IO) {
credentialStore.savePassword(savedConnectionId, result.password)
}
}
doConnect(host, port, username, result.password)
},
onDisconnect = {
finish()
}
)
}
private fun logCursorPos(context: String) {
val buf = terminalView.screenBuffer ?: return
val row = buf.cursorRow
val col = buf.cursorCol
// Read the current line content up to cursor
val line = buf.getLine(row)
val linePreview = if (line.length > col) line.substring(0, col) + "|" + line.substring(col) else line + "|"
FileLogger.log(TAG, "[cursor] $context: row=$row col=$col line='${linePreview.trimEnd()}'")
}
private fun applyQuickBarCtrl(bytes: ByteArray): ByteArray {
val kb = terminalKeyboard ?: return bytes
val states = kb.modifierState.value
val ctrlState = states[com.roundingmobile.keyboard.model.Modifier.CTRL]
?: com.roundingmobile.keyboard.model.ModifierState.IDLE
if (ctrlState == com.roundingmobile.keyboard.model.ModifierState.IDLE) return bytes
if (bytes.size != 1) return bytes
val b = bytes[0].toInt() and 0xFF
val ctrl = when {
b in 0x61..0x7A -> (b - 0x60).toByte() // a-z → 0x01-0x1A
b in 0x41..0x5A -> (b - 0x40).toByte() // A-Z → 0x01-0x1A
b == 0x20 -> 0x00.toByte() // space → NUL
b == 0x5B -> 0x1B.toByte() // [ → ESC
b == 0x5D -> 0x1D.toByte() // ] → GS
b == 0x5C -> 0x1C.toByte() // \ → FS
b == 0x5F -> 0x1F.toByte() // _ → US
else -> return bytes
}
// Consume ARMED state (single-shot CTRL)
if (ctrlState == com.roundingmobile.keyboard.model.ModifierState.ARMED) {
kb.consumeModifier(com.roundingmobile.keyboard.model.Modifier.CTRL)
}
return byteArrayOf(ctrl)
}
private fun systemKeyboardQuickBarKeys(): List<KeyDefinition> {
fun key(id: String, label: String, action: KeyAction, w: Float = 1f,
style: String? = null, repeatable: Boolean = false) =
KeyDefinition(id = id, label = label, action = action, w = w,
style = style, repeatable = repeatable)
return listOf(
key("qb_ctrl", "CTRL", KeyAction.ToggleMod("ctrl"), style = "modifier"),
key("qb_esc", "ESC", KeyAction.Bytes(listOf(27))),
key("qb_tab", "", KeyAction.Bytes(listOf(9))),
key("qb_colon", ":", KeyAction.Char(":")),
key("qb_slash", "/", KeyAction.Char("/")),
key("qb_up", "", KeyAction.EscSeq("[A"), w = 0.8f, repeatable = true),
key("qb_down", "", KeyAction.EscSeq("[B"), w = 0.8f, repeatable = true),
key("qb_left", "", KeyAction.EscSeq("[D"), w = 0.8f, repeatable = true),
key("qb_right", "", KeyAction.EscSeq("[C"), w = 0.8f, repeatable = true),
key("qb_home", "HOME", KeyAction.EscSeq("[H")),
key("qb_end", "END", KeyAction.EscSeq("[F")),
key("qb_pgup", "PGUP", KeyAction.EscSeq("[5~"), repeatable = true),
key("qb_pgdn", "PGDN", KeyAction.EscSeq("[6~"), repeatable = true),
key("qb_f1", "F1", KeyAction.EscSeq("OP")),
key("qb_f2", "F2", KeyAction.EscSeq("OQ")),
key("qb_f3", "F3", KeyAction.EscSeq("OR")),
key("qb_f4", "F4", KeyAction.EscSeq("OS")),
key("qb_f5", "F5", KeyAction.EscSeq("[15~")),
key("qb_f6", "F6", KeyAction.EscSeq("[17~")),
key("qb_f7", "F7", KeyAction.EscSeq("[18~")),
key("qb_f8", "F8", KeyAction.EscSeq("[19~")),
key("qb_f9", "F9", KeyAction.EscSeq("[20~")),
key("qb_f10", "F10", KeyAction.EscSeq("[21~")),
key("qb_f11", "F11", KeyAction.EscSeq("[23~")),
key("qb_f12", "F12", KeyAction.EscSeq("[24~")),
)
}
private fun transformCursorKeys(bytes: ByteArray): ByteArray {
if (bytes.size != 3) return bytes
val screen = terminalView.screenBuffer ?: return bytes

View file

@ -295,7 +295,6 @@ internal fun TerminalActivity.autoSaveConnection(host: String, port: Int, userna
val existing = connectionDao.findByHostPortUser(host, port, username)
if (existing != null) {
connectionDao.updateLastConnected(existing.id, System.currentTimeMillis())
if (password.isNotEmpty()) credentialStore.savePassword(existing.id, password)
savedConnectionId = existing.id
} else {
val name = "$username@$host"
@ -303,7 +302,6 @@ internal fun TerminalActivity.autoSaveConnection(host: String, port: Int, userna
name = name, host = host, port = port,
username = username, lastConnected = System.currentTimeMillis()
))
if (password.isNotEmpty()) credentialStore.savePassword(id, password)
savedConnectionId = id
}
}

View file

@ -5,12 +5,10 @@ import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.text.InputType
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.ScrollView
@ -51,11 +49,11 @@ object TerminalDialogs {
onStay: () -> Unit
) {
val layout = buildDialogContent(context) {
addIcon(it, android.R.drawable.ic_menu_close_clear_cancel, PRIMARY)
addBody(it, "The remote session has closed.\nWould you like to close this terminal?")
}
MaterialAlertDialogBuilder(context)
.setIcon(tintedIcon(context, android.R.drawable.ic_menu_close_clear_cancel, PRIMARY))
.setTitle("Session Ended")
.setView(layout)
.setPositiveButton("Close") { _, _ -> onClose() }
@ -81,7 +79,6 @@ object TerminalDialogs {
val layout = buildDialogContent(context) { root ->
if (isChanged) {
addIcon(root, android.R.drawable.ic_dialog_alert, WARNING)
addBody(root, "The host key for this server has changed.\nThis could indicate a security issue.")
addSpacer(root, 12)
addLabel(root, "SERVER")
@ -93,7 +90,6 @@ object TerminalDialogs {
addLabel(root, "NEW FINGERPRINT")
addMonoBox(root, fingerprint, WARNING)
} else {
addIcon(root, android.R.drawable.ic_lock_idle_lock, PRIMARY)
addBody(root, "First connection to this server.\nVerify the fingerprint matches what you expect.")
addSpacer(root, 12)
addLabel(root, "SERVER")
@ -108,8 +104,11 @@ object TerminalDialogs {
}
val title = if (isChanged) "Host Key Changed" else "New Host Key"
val iconRes = if (isChanged) android.R.drawable.ic_dialog_alert else android.R.drawable.ic_lock_idle_lock
val iconTint = if (isChanged) WARNING else PRIMARY
MaterialAlertDialogBuilder(context)
.setIcon(tintedIcon(context, iconRes, iconTint))
.setTitle(title)
.setView(layout)
.setPositiveButton("Accept") { _, _ -> onResult(HostKeyAction.ACCEPT) }
@ -134,8 +133,6 @@ object TerminalDialogs {
val inputLayouts = mutableListOf<TextInputLayout>()
val layout = buildDialogContent(context) { root ->
addIcon(root, android.R.drawable.ic_lock_idle_lock, PRIMARY)
if (instruction.isNotBlank()) {
addBody(root, instruction)
addSpacer(root, 8)
@ -192,6 +189,7 @@ object TerminalDialogs {
}
MaterialAlertDialogBuilder(context)
.setIcon(tintedIcon(context, android.R.drawable.ic_lock_idle_lock, PRIMARY))
.setTitle("Server Authentication")
.setView(layout)
.setPositiveButton("OK") { _, _ ->
@ -207,6 +205,109 @@ object TerminalDialogs {
.show()
}
// ========================================================================
// Password prompt dialog (no password stored or auth failed)
// ========================================================================
data class PasswordResult(val password: String, val remember: Boolean)
fun showPasswordDialog(
context: Context,
host: String,
username: String,
errorMessage: String? = null,
onConnect: (PasswordResult) -> Unit,
onDisconnect: () -> Unit
) {
var dialogRef: androidx.appcompat.app.AlertDialog? = null
val layout = buildDialogContent(context) { root ->
if (errorMessage != null) {
val errorText = TextView(context).apply {
text = errorMessage
setTextColor(Color.parseColor("#FF6B6B"))
textSize = 13f
setPadding(0, 0, 0, 8.dp(context))
}
root.addView(errorText)
}
val subtitle = TextView(context).apply {
text = "$username@$host"
setTextColor(ON_SURFACE_VARIANT)
textSize = 14f
setPadding(0, 0, 0, 12.dp(context))
}
root.addView(subtitle)
val til = TextInputLayout(context, null,
com.google.android.material.R.attr.textInputOutlinedStyle
).apply {
hint = context.getString(com.roundingmobile.sshworkbench.R.string.password)
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
boxStrokeColor = PRIMARY
setHintTextColor(android.content.res.ColorStateList.valueOf(ON_SURFACE_VARIANT))
}
val edit = TextInputEditText(context).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
setTextColor(ON_SURFACE)
maxLines = 1
imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_DONE
setOnEditorActionListener { _, actionId, _ ->
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_DONE) {
dialogRef?.getButton(android.content.DialogInterface.BUTTON_POSITIVE)?.performClick()
true
} else false
}
}
til.addView(edit)
root.addView(til)
til.tag = "password_input"
}
val rememberBox = CheckBox(context).apply {
text = context.getString(com.roundingmobile.sshworkbench.R.string.remember_password)
setTextColor(ON_SURFACE_VARIANT)
buttonTintList = android.content.res.ColorStateList.valueOf(PRIMARY)
setPadding(0, 4.dp(context), 0, 0)
}
(layout as LinearLayout).addView(rememberBox, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
marginStart = 24.dp(context)
marginEnd = 24.dp(context)
bottomMargin = 16.dp(context)
})
dialogRef = MaterialAlertDialogBuilder(context)
.setIcon(tintedIcon(context, android.R.drawable.ic_lock_idle_lock, PRIMARY))
.setTitle(context.getString(com.roundingmobile.sshworkbench.R.string.password))
.setView(layout)
.setPositiveButton(context.getString(com.roundingmobile.sshworkbench.R.string.connect)) { _, _ ->
val passwordInput = layout.findViewWithTag<TextInputLayout>("password_input")
val password = (passwordInput?.editText as? TextInputEditText)?.text?.toString() ?: ""
onConnect(PasswordResult(password, rememberBox.isChecked))
}
.setNegativeButton(context.getString(com.roundingmobile.sshworkbench.R.string.disconnect)) { _, _ ->
onDisconnect()
}
.setCancelable(false)
.create()
dialogRef.window?.setSoftInputMode(
android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or
android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN
)
dialogRef.show()
// Focus the password field and show keyboard
val passwordEdit = (layout.findViewWithTag<TextInputLayout>("password_input"))?.editText
passwordEdit?.post {
passwordEdit.requestFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.showSoftInput(passwordEdit, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
}
}
// ========================================================================
// SSH connect dialog
// ========================================================================
@ -435,17 +536,11 @@ object TerminalDialogs {
return root
}
private fun addIcon(root: LinearLayout, resId: Int, tint: Int) {
val ctx = root.context
val icon = ImageView(ctx).apply {
setImageResource(resId)
setColorFilter(tint)
layoutParams = LinearLayout.LayoutParams(32.dp(ctx), 32.dp(ctx)).apply {
gravity = Gravity.CENTER_HORIZONTAL
bottomMargin = 12.dp(ctx)
}
}
root.addView(icon)
/** Create a tinted drawable for use with MaterialAlertDialogBuilder.setIcon() */
private fun tintedIcon(context: Context, resId: Int, tint: Int): android.graphics.drawable.Drawable {
val drawable = context.resources.getDrawable(resId, null).mutate()
drawable.setColorFilter(tint, android.graphics.PorterDuff.Mode.SRC_IN)
return drawable
}
private fun addBody(root: LinearLayout, text: String) {

View file

@ -183,6 +183,8 @@ class EditConnectionViewModel @Inject constructor(
connectionDao.update(updated)
if (authType == "password" && password.isNotEmpty()) {
credentialStore.savePassword(editingId, password)
} else {
credentialStore.deletePassword(editingId)
}
} else {
val newConn = SavedConnection(
@ -243,6 +245,8 @@ class EditConnectionViewModel @Inject constructor(
connectionDao.update(updated)
if (authType == "password" && password.isNotEmpty()) {
credentialStore.savePassword(editingId, password)
} else {
credentialStore.deletePassword(editingId)
}
editingId
} else {

View file

@ -4,4 +4,6 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt.android) apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.firebase.crashlytics.plugin) apply false
}

110
docs/TESTING.md Normal file
View file

@ -0,0 +1,110 @@
# Terminal Testing via ADB
## Overview
Automated tests can be sent to the terminal via ADB broadcast receiver.
The app registers `com.roundingmobile.sshworkbench.INPUT` in debug builds.
## Prerequisites
1. App installed on device: `adb install -r app/build/deploy/ssh-workbench.v1.0.0.pro.dbg.YYYY-MM-DD.apk`
2. Connected to a session (e.g. Duero)
3. Device connected via ADB: `adb devices`
## Broadcast API
Action: `com.roundingmobile.sshworkbench.INPUT`
| Extra | Type | Description |
|-------|------|-------------|
| `text` | String | Send text to the terminal |
| `enter` | Boolean | Send Enter (0x0D) |
| `esc` | String | Send ESC + string (e.g. `[D` for left arrow) |
| `bytes` | String | Send raw hex bytes (e.g. `1b 62` for ESC b) |
| `log` | Boolean | Log cursor position after 100ms delay |
### Examples
```bash
# Send text
adb shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es text 'hello world'"
# Send Enter
adb shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --ez enter true"
# Send left arrow (ESC [D)
adb shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es esc '[D'"
# Send ESC b (word back)
adb shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es bytes '1b 62'"
# Send Ctrl+U (kill line)
adb shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es bytes '15'"
# Log cursor position
adb shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --ez log true"
```
## Cursor Position Logging
When `log` extra is sent, or before any input, the app logs cursor position:
```
[cursor] before-text 'hello': row=3 col=14 line='jima@duero:~$ |'
[cursor] query: row=3 col=19 line='jima@duero:~$ hello|'
```
The `|` in the line preview shows the cursor position. Read logs via:
```bash
adb shell cat /sdcard/Download/SshWorkbench/sshworkbench_debug.txt | grep "\[cursor\]"
```
## Common Escape Sequences
| Key | Escape | Bytes |
|-----|--------|-------|
| Left arrow | `[D` | `1b 5b 44` |
| Right arrow | `[C` | `1b 5b 43` |
| Up arrow | `[A` | `1b 5b 41` |
| Down arrow | `[B` | `1b 5b 42` |
| Home | `[H` | `1b 5b 48` |
| End | `[F` | `1b 5b 46` |
| Word back (Alt+Left) | — | `1b 62` |
| Word forward (Alt+Right) | — | `1b 66` |
| Ctrl+U (kill line) | — | `15` |
| Ctrl+C (interrupt) | — | `03` |
| Tab | — | `09` |
## Running the Test Script
```bash
# Make sure you're connected to a session first, then:
./scripts/test_terminal.sh
```
The script tests:
1. Text input and cursor tracking
2. Word navigation (ESC b / ESC f)
3. Arrow key movement
4. Home / End keys
Screenshots are saved to `/tmp/zebra_test_*.png`.
## Connecting Automatically
To connect to Duero via ADB without manual taps:
```bash
# 1. Launch connection manager
adb shell am start -n com.roundingmobile.sshworkbench.pro/com.roundingmobile.sshworkbench.ui.ConnectionManagerActivity
# 2. Get UI element bounds
adb shell uiautomator dump /sdcard/ui.xml
adb shell cat /sdcard/ui.xml | grep -o 'text="Duero"[^>]*bounds="[^"]*"'
# 3. Tap the connection (bounds are [32,336][688,520], center = 360,428)
adb shell input tap 360 428
```
Note: UI coordinates may change if the connection list order changes.

View file

@ -22,6 +22,9 @@ composeBom = "2025.05.00"
navigationCompose = "2.9.6"
hiltNavigationCompose = "1.2.0"
biometric = "1.1.0"
firebaseBom = "34.11.0"
googleServices = "4.4.4"
firebaseCrashlyticsPlugin = "3.0.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -84,6 +87,11 @@ lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-ru
# Biometric
biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
# Firebase
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
@ -91,3 +99,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
firebase-crashlytics-plugin = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" }

View file

@ -13,6 +13,10 @@ import android.view.ViewGroup
import android.widget.FrameLayout
import com.roundingmobile.keyboard.gesture.KeyRepeatHandler
import com.roundingmobile.keyboard.model.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import com.roundingmobile.keyboard.model.KeyEvent
import com.roundingmobile.keyboard.parser.LanguageParser
import com.roundingmobile.keyboard.parser.LayoutParser
@ -62,6 +66,34 @@ class TerminalKeyboard private constructor(
private var longPressKey: KeyDefinition? = null
private var isLongPressActive = false
private var modLongPressRunnable: Runnable? = null
private var modStateJob: Job? = null
// Double-tap state (shared between keyboard pages and quick bar)
private var lastTapKeyId: String? = null
private var lastTapTime: Long = 0
private val doubleTapTimeout = 300L
/** Check if this key press is a double-tap. Returns the word-jump action or null. */
private fun checkDoubleTapWordJump(key: KeyDefinition): KeyAction? {
val now = System.currentTimeMillis()
val isDoubleTap = key.id == lastTapKeyId && (now - lastTapTime) < doubleTapTimeout
if (!isDoubleTap) return null
lastTapKeyId = null
lastTapTime = 0
return when (key.action) {
is KeyAction.EscSeq -> when ((key.action as KeyAction.EscSeq).seq) {
"[D" -> KeyAction.Bytes(listOf(0x1B, 0x62)) // ESC b — word back
"[C" -> KeyAction.Bytes(listOf(0x1B, 0x66)) // ESC f — word forward
else -> null
}
else -> null
}
}
private fun recordTap(key: KeyDefinition) {
lastTapKeyId = key.id
lastTapTime = System.currentTimeMillis()
}
private var modLongPressFired = false
// --- Visibility ---
@ -144,22 +176,60 @@ class TerminalKeyboard private constructor(
val qb = QuickBarView(context)
qb.scrollStyle = layout.settings.quickBarScrollStyle
qb.setup(qbConfig, theme)
qb.onKeyTap = { key -> handleKeyAction(key) }
qb.onKeyTap = { key ->
// Don't fire on tap if repeat already handled it
if (!key.repeatable) handleKeyAction(key)
recordTap(key)
}
qb.onSnippetsTap = { onSnippetsTap?.invoke() }
qb.onKeyDown = { key ->
hapticFeedback(qb)
if (layout.settings.showHints && key.label.length <= 3) {
showQuickBarHint(key, qb)
}
if (key.repeatable) {
val wordAction = checkDoubleTapWordJump(key)
if (wordAction != null) {
val wordKey = key.copy(action = wordAction)
handleKeyAction(wordKey)
keyRepeatHandler.start(350, 300) { handleKeyAction(wordKey) }
} else {
handleKeyAction(key)
keyRepeatHandler.start { handleKeyAction(key) }
}
}
}
qb.onKeyUp = {
hintPopup?.hide()
keyRepeatHandler.stop()
}
qb.onKeyUp = { hintPopup?.hide() }
container.addView(qb, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
))
quickBarView = qb
// Observe modifier state to highlight active modifiers on the quick bar
modStateJob?.cancel()
modStateJob = CoroutineScope(Dispatchers.Main).launch {
modifierManager.states.collect { states: Map<Modifier, ModifierState> ->
val active = mutableSetOf<String>()
for ((mod, state) in states) {
if (state != ModifierState.IDLE) {
active.add("qb_${mod.name.lowercase()}")
}
}
if (qb.activeModifiers != active) {
qb.activeModifiers.clear()
qb.activeModifiers.addAll(active)
qb.invalidate()
}
}
}
}
fun detach() {
modStateJob?.cancel()
keyRepeatHandler.destroy()
keyboardView?.let { (it.parent as? ViewGroup)?.removeView(it) }
quickBarView?.let { (it.parent as? ViewGroup)?.removeView(it) }
@ -207,9 +277,15 @@ class TerminalKeyboard private constructor(
// Start long press timer or key repeat
if (key.repeatable) {
// Immediate fire + repeat
handleKeyAction(key)
keyRepeatHandler.start { handleKeyAction(key) }
val wordAction = checkDoubleTapWordJump(key)
if (wordAction != null) {
val wordKey = key.copy(action = wordAction)
handleKeyAction(wordKey)
keyRepeatHandler.start(350, 300) { handleKeyAction(wordKey) }
} else {
handleKeyAction(key)
keyRepeatHandler.start { handleKeyAction(key) }
}
} else if (key.action is KeyAction.ToggleMod) {
// Long-press on modifier = lock (caps lock for Shift)
scheduleModLongPress(key)
@ -276,6 +352,7 @@ class TerminalKeyboard private constructor(
if (key != null && !key.repeatable && !modLongPressFired) {
handleKeyAction(key)
}
if (key != null) recordTap(key)
modLongPressFired = false
return true
}
@ -518,13 +595,22 @@ class TerminalKeyboard private constructor(
if (quickBarView?.visibility == View.VISIBLE) hideQuickBar() else showQuickBar()
}
/** Overridden quick bar keys — if set, setTheme preserves these instead of resetting to layout defaults */
private var quickBarKeysOverride: List<KeyDefinition>? = null
fun setTheme(newTheme: KeyboardTheme) {
theme = newTheme
keyboardView?.updateTheme(newTheme)
hintPopup?.theme = newTheme
longPressPopup?.theme = newTheme
quickBarView?.let { qb ->
layout.quickBar?.let { cfg -> qb.setup(cfg, newTheme) }
val overrideKeys = quickBarKeysOverride
if (overrideKeys != null) {
val cfg = layout.quickBar?.copy(keys = overrideKeys) ?: QuickBarConfig(keys = overrideKeys)
qb.setup(cfg, newTheme)
} else {
layout.quickBar?.let { cfg -> qb.setup(cfg, newTheme) }
}
}
}
@ -532,6 +618,17 @@ class TerminalKeyboard private constructor(
keyboardView?.setPageIndicatorVisible(visible)
}
fun setQuickBarKeys(keys: List<KeyDefinition>) {
quickBarKeysOverride = keys
val cfg = layout.quickBar?.copy(keys = keys) ?: QuickBarConfig(keys = keys)
quickBarView?.setup(cfg, theme)
}
/** Consume (reset to IDLE) a specific ARMED modifier — used by external input paths (system keyboard) */
fun consumeModifier(mod: Modifier) {
modifierManager.consumeArmed()
}
fun setQuickBarOrientation(orientation: QuickBarOrientation) {
quickBarView?.setOrientation(orientation)
}

View file

@ -14,12 +14,16 @@ class KeyRepeatHandler(
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun start(onRepeat: () -> Unit) {
start(repeatDelay, repeatInterval, onRepeat)
}
fun start(delayMs: Int, intervalMs: Int, onRepeat: () -> Unit) {
stop()
job = scope.launch {
delay(repeatDelay.toLong())
delay(delayMs.toLong())
while (isActive) {
onRepeat()
delay(repeatInterval.toLong())
delay(intervalMs.toLong())
}
}
}

View file

@ -31,6 +31,9 @@ class QuickBarView(context: Context) : View(context) {
/** Callback for the snippets button */
var onSnippetsTap: (() -> Unit)? = null
/** Active (ARMED/LOCKED) modifier key IDs — drawn with highlight color */
val activeModifiers = mutableSetOf<String>()
// Scroll style
var scrollStyle: String = "infinite"
@ -82,6 +85,8 @@ class QuickBarView(context: Context) : View(context) {
theme = keyboardTheme
allKeys.clear()
allKeys.addAll(quickBarConfig.keys.filter { it.visible })
keyWidths.clear()
scrollOffset = 0f
quickBarConfig.background?.let {
try { bgPaint.color = Color.parseColor(it) } catch (_: Exception) {}
@ -95,6 +100,10 @@ class QuickBarView(context: Context) : View(context) {
separatorPaint.color = Color.rgb(r, g, b)
alpha = quickBarConfig.opacity
// Recompute key metrics immediately if view is already laid out
if (width > 0 && height > 0) {
computeKeyMetrics(width.toFloat(), height.toFloat())
}
requestLayout()
invalidate()
}
@ -134,13 +143,11 @@ class QuickBarView(context: Context) : View(context) {
val snippetSpace = snippetBtnWidth + gapPx
val isVertical = config?.orientation == QuickBarOrientation.VERTICAL
// For infinite mode, compute natural key sizes along scroll axis
val availSize = if (isVertical) {
viewH - gripSpace - snippetSpace - gapPx * (keys.size + 1)
} else {
viewW - gripSpace - snippetSpace - gapPx * (keys.size + 1)
}
val unitSize = availSize / totalWeight
// For infinite scroll, size keys so the smallest key is at least 48dp wide.
// This determines how many keys fit in the viewport; the rest scroll.
val minKeyWidthPx = dpToPx(36f)
val smallestWeight = keys.minOf { it.w }
val unitSize = minKeyWidthPx / smallestWeight
for (key in keys) {
keyWidths.add(unitSize * key.w)
}
@ -344,14 +351,19 @@ class QuickBarView(context: Context) : View(context) {
private fun drawSingleKey(canvas: Canvas, th: KeyboardTheme, key: KeyDefinition, rect: RectF) {
val pressed = key.id in pressedKeys
keyBgPaint.color = if (pressed) th.resolveKeyBgPressed(key.style) else th.resolveKeyBg(key.style)
val modActive = key.id in activeModifiers
keyBgPaint.color = when {
modActive -> 0xFF4CAF50.toInt() // green highlight for active modifiers
pressed -> th.resolveKeyBgPressed(key.style)
else -> th.resolveKeyBg(key.style)
}
canvas.drawRoundRect(rect, radiusPx, radiusPx, keyBgPaint)
borderPaint.color = th.resolveKeyBorder(key.style)
canvas.drawRoundRect(rect, radiusPx, radiusPx, borderPaint)
textPaint.color = th.resolveKeyText(key.style)
textPaint.textSize = spToPx(11f)
textPaint.color = if (modActive) 0xFFFFFFFF.toInt() else th.resolveKeyText(key.style)
textPaint.textSize = if (key.label.length == 1) spToPx(16f) else spToPx(11f)
canvas.drawText(key.label, rect.centerX(), rect.centerY() + textPaint.textSize / 3f, textPaint)
}