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:
parent
34fd76e48d
commit
73e7629a5a
13 changed files with 538 additions and 53 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -48,6 +48,11 @@ proguard/
|
|||
*.jks
|
||||
*.keystore
|
||||
|
||||
# =========================
|
||||
# Firebase / Google Services
|
||||
# =========================
|
||||
google-services.json
|
||||
|
||||
# =========================
|
||||
# OS junk
|
||||
# =========================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
110
docs/TESTING.md
Normal 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.
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue