100% Compose migration, AQB/CQB separation, password prompt, ADB test framework

Compose migration (4 files, 2059 lines of View code → Compose):
- ThemePickerDialog: Compose Canvas preview, LazyColumn theme list
- TerminalDialogs: 6 dead dialogs deleted, 2 migrated (hostKey, authPrompt)
  with TerminalDialogRequest sealed interface on MainViewModel for
  imperative→declarative bridge. Added PasswordDialog for no-stored-pw case.
- SnippetDialogs: full-screen Compose dialog with LazyColumn, search,
  inline create form, context menu
- KeyboardSettingsDialog: TabRow, Slider, Canvas preview, color picker.
  Data classes extracted to KeyboardModels.kt

AQB/CQB separation:
- Independent Quick Bar preferences for AKB vs CKB modes
- Settings UI mode-aware: CKB shows full keyboard dialog, AKB shows QB-only
- AQB positions filtered (no above/below keyboard)
- New docs: KEYBOARD.md, GLOSSARY.md (TV, AKB, CKB, AQB, CQB)

Password prompt:
- TerminalService prompts for password when no stored auth (SSHAuth.None)
- Compose PasswordDialog with remember checkbox
- clearPassword ADB broadcast for test cleanup

ADB test framework (Python):
- test.py runner with menu, --all, single test modes
- AI visual verification via claude -p reading screenshots
- 5 tests, 52 checks: connect, htop, vim, password prompt, multi-session
- Timestamped results with manifest.json for cross-run comparison

Coding conventions updated: 100% Compose mandated, no programmatic Views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-03 15:00:35 +02:00
parent bb7662ca63
commit 2a87fb58d1
35 changed files with 3942 additions and 2116 deletions

View file

@ -2,5 +2,5 @@ package com.roundingmobile.sshworkbench
// Auto-generated — do not edit
object BuildTimestamp {
const val TIME = "2026-04-03 08:41:58"
const val TIME = "2026-04-03 12:55:21"
}

View file

@ -39,6 +39,11 @@ object TerminalPrefsKeys {
val QB_COLOR_PRESET = stringPreferencesKey("qb_color_preset")
val QB_COLOR_CUSTOM = stringPreferencesKey("qb_color_custom") // hex #RRGGBB
val KEYBOARD_TYPE = stringPreferencesKey("keyboard_type")
// AQB — Quick Bar settings when using Android (system) keyboard
val AQB_POSITION = stringPreferencesKey("aqb_position")
val AQB_SIZE = intPreferencesKey("aqb_size")
val AQB_COLOR_PRESET = stringPreferencesKey("aqb_color_preset")
val AQB_COLOR_CUSTOM = stringPreferencesKey("aqb_color_custom")
val APP_LANGUAGE = stringPreferencesKey("app_language")
val SHOW_KEY_HINTS = booleanPreferencesKey("show_key_hints")
val KEY_REPEAT_DELAY = intPreferencesKey("key_repeat_delay")
@ -238,6 +243,39 @@ class TerminalPreferences(private val dataStore: DataStore<Preferences>) {
dataStore.edit { prefs -> prefs[TerminalPrefsKeys.KEYBOARD_TYPE] = type }
}
// AQB — Quick Bar prefs for system keyboard mode
val aqbPosition: Flow<String> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.AQB_POSITION] ?: "top"
}
suspend fun setAqbPosition(position: String) {
dataStore.edit { prefs -> prefs[TerminalPrefsKeys.AQB_POSITION] = position }
}
val aqbSize: Flow<Int> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.AQB_SIZE] ?: 42
}
suspend fun setAqbSize(sizeDp: Int) {
dataStore.edit { prefs -> prefs[TerminalPrefsKeys.AQB_SIZE] = sizeDp }
}
val aqbColorPreset: Flow<String> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.AQB_COLOR_PRESET] ?: "default"
}
suspend fun setAqbColorPreset(preset: String) {
dataStore.edit { prefs -> prefs[TerminalPrefsKeys.AQB_COLOR_PRESET] = preset }
}
val aqbColorCustom: Flow<String> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.AQB_COLOR_CUSTOM] ?: ""
}
suspend fun setAqbColorCustom(hex: String) {
dataStore.edit { prefs -> prefs[TerminalPrefsKeys.AQB_COLOR_CUSTOM] = hex }
}
val appLanguage: Flow<String> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.APP_LANGUAGE] ?: "system"
}

View file

@ -0,0 +1,96 @@
package com.roundingmobile.sshworkbench.terminal
import android.graphics.Color
/**
* All keyboard + quick bar display settings persisted in DataStore.
*/
data class KeyboardDisplaySettings(
// Keyboard
val language: String = "en",
val heightPercent: Float = 0.27f,
val heightLandscape: Float = 0.27f,
val sameSizeBoth: Boolean = true,
val showPageIndicators: Boolean = true,
val keyColorPreset: String = "default",
val keyColorCustom: String = "", // hex #RRGGBB or empty
val showHints: Boolean = true,
val keyRepeatDelay: Int = 400,
val longPressDelay: Int = 350,
// Quick Bar
val quickBarPosition: String = "above_keyboard",
val quickBarSize: Int = 42, // dp
val qbColorPreset: String = "default",
val qbColorCustom: String = ""
) {
fun toJson(): String = org.json.JSONObject().apply {
put("language", language)
put("heightPercent", heightPercent.toDouble())
put("heightLandscape", heightLandscape.toDouble())
put("sameSizeBoth", sameSizeBoth)
put("showPageIndicators", showPageIndicators)
put("keyColorPreset", keyColorPreset)
put("keyColorCustom", keyColorCustom)
put("showHints", showHints)
put("keyRepeatDelay", keyRepeatDelay)
put("longPressDelay", longPressDelay)
put("quickBarPosition", quickBarPosition)
put("quickBarSize", quickBarSize)
put("qbColorPreset", qbColorPreset)
put("qbColorCustom", qbColorCustom)
}.toString()
companion object {
fun fromJson(json: String): KeyboardDisplaySettings? {
if (json.isBlank()) return null
return try {
val j = org.json.JSONObject(json)
val defaults = KeyboardDisplaySettings()
KeyboardDisplaySettings(
language = j.optString("language", defaults.language),
heightPercent = j.optDouble("heightPercent", defaults.heightPercent.toDouble()).toFloat(),
heightLandscape = j.optDouble("heightLandscape", defaults.heightLandscape.toDouble()).toFloat(),
sameSizeBoth = j.optBoolean("sameSizeBoth", defaults.sameSizeBoth),
showPageIndicators = j.optBoolean("showPageIndicators", defaults.showPageIndicators),
keyColorPreset = j.optString("keyColorPreset", defaults.keyColorPreset),
keyColorCustom = j.optString("keyColorCustom", defaults.keyColorCustom),
showHints = j.optBoolean("showHints", defaults.showHints),
keyRepeatDelay = j.optInt("keyRepeatDelay", defaults.keyRepeatDelay),
longPressDelay = j.optInt("longPressDelay", defaults.longPressDelay),
quickBarPosition = j.optString("quickBarPosition", defaults.quickBarPosition),
quickBarSize = j.optInt("quickBarSize", defaults.quickBarSize),
qbColorPreset = j.optString("qbColorPreset", defaults.qbColorPreset),
qbColorCustom = j.optString("qbColorCustom", defaults.qbColorCustom)
)
} catch (_: Exception) { null }
}
}
}
/**
* Quick Bar display settings used for AQB (system keyboard mode).
*/
data class QuickBarDisplaySettings(
val position: String = "top",
val size: Int = 42,
val colorPreset: String = "default",
val colorCustom: String = ""
)
/** Color presets for keys. */
object KeyColorPresets {
data class Preset(val name: String, val displayName: String, val swatch: Int, val keyBg: Int, val keyText: Int)
val all = listOf(
Preset("default", "Default", Color.parseColor("#4A4A4F"), Color.parseColor("#4A4A4F"), Color.parseColor("#E8E8E8")),
Preset("ocean", "Ocean", Color.parseColor("#1A3A5C"), Color.parseColor("#1A3A5C"), Color.parseColor("#7EB8E8")),
Preset("forest", "Forest", Color.parseColor("#1A4A2A"), Color.parseColor("#1A4A2A"), Color.parseColor("#7EE8A5")),
Preset("sunset", "Sunset", Color.parseColor("#5C3A1A"), Color.parseColor("#5C3A1A"), Color.parseColor("#E8B87E")),
Preset("grape", "Grape", Color.parseColor("#3A1A5C"), Color.parseColor("#3A1A5C"), Color.parseColor("#B87EE8")),
Preset("mono", "Mono", Color.parseColor("#3A3A3A"), Color.parseColor("#3A3A3A"), Color.parseColor("#CCCCCC")),
Preset("nord", "Nord", Color.parseColor("#3B4252"), Color.parseColor("#3B4252"), Color.parseColor("#D8DEE9")),
Preset("dracula", "Dracula", Color.parseColor("#44475A"), Color.parseColor("#44475A"), Color.parseColor("#F8F8F2"))
)
fun find(name: String) = all.find { it.name == name } ?: all[0]
}

View file

@ -1,106 +1,3 @@
package com.roundingmobile.sshworkbench.terminal
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.view.View
/**
* Miniature phone preview showing keyboard + quick bar layout.
* Used in the keyboard settings dialog to visualize changes in real-time.
*/
internal class KeyboardPreviewView(context: Context) : View(context) {
var heightPercent = 0.27f
var quickBarPosition = "above_keyboard"
var quickBarSizeDp = 42
var showIndicators = true
var presetColor = Color.parseColor("#4A4A4F")
var qbColor = Color.parseColor("#2A2A3E")
private val screenPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#111111") }
private val terminalPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#1A1A2E") }
private val keyboardBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#1E1E20") }
private val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val quickBarPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val indicatorPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#444444"); style = Paint.Style.STROKE; strokeWidth = 2f
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#888888"); textSize = 20f; textAlign = Paint.Align.CENTER
}
override fun onDraw(canvas: Canvas) {
val w = width.toFloat(); val h = height.toFloat()
val margin = 8f
val phoneW = w * 0.5f; val phoneH = h - margin * 2
val phoneL = (w - phoneW) / 2; val phoneT = margin; val radius = 12f
canvas.drawRoundRect(phoneL, phoneT, phoneL + phoneW, phoneT + phoneH, radius, radius, screenPaint)
canvas.drawRoundRect(phoneL, phoneT, phoneL + phoneW, phoneT + phoneH, radius, radius, borderPaint)
val m = 4f
val iL = phoneL + m; val iT = phoneT + m; val iW = phoneW - m * 2; val iH = phoneH - m * 2
val kbH = iH * heightPercent
val qbH = iH * (quickBarSizeDp / 800f).coerceIn(0.03f, 0.12f)
val indH = if (showIndicators) iH * 0.03f else 0f
val isVert = quickBarPosition == "vertical_left" || quickBarPosition == "vertical_right"
quickBarPaint.color = qbColor
if (isVert) {
val qbW = iW * (quickBarSizeDp / 500f).coerceIn(0.05f, 0.15f)
val qbL = if (quickBarPosition == "vertical_left") iL else iL + iW - qbW
val kbTop = iT + iH - kbH
canvas.drawRect(iL, iT, iL + iW, kbTop, terminalPaint)
canvas.drawRect(qbL, iT, qbL + qbW, kbTop, quickBarPaint)
textPaint.textSize = 16f
canvas.drawText("Terminal", iL + iW / 2, iT + (kbTop - iT) / 2 + 6, textPaint)
canvas.drawRect(iL, kbTop, iL + iW, iT + iH, keyboardBgPaint)
drawKeys(canvas, iL + 2, kbTop + 2, iW - 4, kbH - indH - 4)
if (showIndicators) drawIndicators(canvas, iL, iT + iH - indH, iW, indH)
} else {
var termTop = iT; val kbTop: Float; var termBottom: Float
when (quickBarPosition) {
"top" -> {
canvas.drawRect(iL, iT, iL + iW, iT + qbH, quickBarPaint)
termTop = iT + qbH; kbTop = iT + iH - kbH; termBottom = kbTop
}
"below_keyboard" -> {
kbTop = iT + iH - kbH - qbH; termBottom = kbTop
canvas.drawRect(iL, iT + iH - qbH, iL + iW, iT + iH, quickBarPaint)
}
else -> {
kbTop = iT + iH - kbH; val qbTop = kbTop - qbH; termBottom = qbTop
canvas.drawRect(iL, qbTop, iL + iW, kbTop, quickBarPaint)
}
}
canvas.drawRect(iL, termTop, iL + iW, termBottom, terminalPaint)
textPaint.textSize = 16f
canvas.drawText("Terminal", iL + iW / 2, termTop + (termBottom - termTop) / 2 + 6, textPaint)
canvas.drawRect(iL, kbTop, iL + iW, kbTop + kbH, keyboardBgPaint)
drawKeys(canvas, iL + 2, kbTop + 2, iW - 4, kbH - indH - 4)
if (showIndicators) drawIndicators(canvas, iL, kbTop + kbH - indH, iW, indH)
}
}
private fun drawKeys(canvas: Canvas, l: Float, t: Float, w: Float, h: Float) {
keyPaint.color = presetColor
val rows = 5; val cols = 10; val gap = 1.5f
val kw = (w - gap * (cols + 1)) / cols; val kh = (h - gap * (rows + 1)) / rows
for (r in 0 until rows) for (c in 0 until cols) {
val kx = l + gap + c * (kw + gap); val ky = t + gap + r * (kh + gap)
canvas.drawRoundRect(kx, ky, kx + kw, ky + kh, 3f, 3f, keyPaint)
}
}
private fun drawIndicators(canvas: Canvas, l: Float, t: Float, w: Float, h: Float) {
val dotR = h * 0.25f; val spacing = w * 0.06f; val count = 4
val startX = l + w / 2 - (count - 1) * spacing / 2; val cy = t + h / 2
val colors = intArrayOf(0xFF55EFC4.toInt(), 0xFFFDCB6E.toInt(), 0xFFA29BFE.toInt(), 0xFFFF7675.toInt())
for (i in 0 until count) {
indicatorPaint.color = colors[i]
canvas.drawCircle(startX + i * spacing, cy, if (i == 0) dotR * 1.3f else dotR, indicatorPaint)
}
}
}
// Replaced by KeyboardPreview composable in KeyboardSettingsDialog.kt
// This file is kept empty to avoid breaking any stale references during transition.
// Safe to delete once all usages are confirmed removed.

View file

@ -1,377 +1,457 @@
package com.roundingmobile.sshworkbench.terminal
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.Editable
import android.text.TextWatcher
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.roundingmobile.sshworkbench.R
import com.roundingmobile.sshworkbench.data.local.Snippet
private val Teal = Color(0xFF55EFC4)
private val ScopeBadgeLocal = Color(0xFFFFB020)
private val ScopeBadgeGlobal = Color(0xFF888888)
/**
* Dialog helpers for snippet management in the terminal screen.
* Full-screen snippet picker dialog.
*
* Displays a searchable list of snippets with inline create form.
* Tap = send with Enter (onSendNow), long-press = context menu.
*
* Edit and Duplicate flows are handled internally via [EditSnippetDialog].
* All DB mutations are delegated to the caller via callbacks.
*/
object SnippetDialogs {
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun SnippetPickerSheet(
snippets: List<Snippet>,
maxSnippets: Int,
savedConnectionId: Long,
onInsert: (Snippet) -> Unit,
onSendNow: (Snippet) -> Unit,
onSave: (name: String, content: String, connectionId: Long?) -> Unit,
onUpdate: (Snippet) -> Unit,
onDelete: (Snippet) -> Unit,
onDuplicate: (Snippet, onCreated: (Snippet) -> Unit) -> Unit,
onDismiss: () -> Unit,
limitReachedMessage: String
) {
var searchQuery by remember { mutableStateOf("") }
var showCreateForm by remember { mutableStateOf(false) }
var createContent by remember { mutableStateOf("") }
var createName by remember { mutableStateOf("") }
var createPerHost by remember { mutableStateOf(savedConnectionId > 0) }
/**
* Show the snippets bottom sheet with search + list.
*/
fun showSnippetPicker(
context: Context,
snippets: List<Snippet>,
maxSnippets: Int,
savedConnectionId: Long,
onInsert: (Snippet) -> Unit,
onSendNow: (Snippet) -> Unit,
onEdit: (Snippet) -> Unit,
onDelete: (Snippet) -> Unit,
onDuplicate: (Snippet) -> Unit = {},
onSave: (name: String, content: String, connectionId: Long?) -> Unit,
refreshSnippets: (callback: (List<Snippet>) -> Unit) -> Unit = {}
// Internal state for editing a snippet (from context menu Edit or Duplicate)
var editingSnippet by remember { mutableStateOf<Snippet?>(null) }
val filteredSnippets = remember(snippets, searchQuery) {
if (searchQuery.isBlank()) snippets
else {
val q = searchQuery.lowercase()
snippets.filter {
it.name.lowercase().contains(q) || it.content.lowercase().contains(q)
}
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
val dialog = BottomSheetDialog(context)
val dp = context.resources.displayMetrics.density
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.snippets)) },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = null)
}
},
actions = {
IconButton(onClick = {
if (snippets.size >= maxSnippets) {
// Limit message shown via Toast by caller; toggling still blocked
} else {
showCreateForm = !showCreateForm
}
}) {
Icon(Icons.Default.Add, contentDescription = null, tint = Teal)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
containerColor = MaterialTheme.colorScheme.surface
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(stringResource(R.string.snippet_search_hint)) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Teal,
cursorColor = Teal
)
)
val screenHeight = context.resources.displayMetrics.heightPixels
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.parseColor("#1E1E2E"))
setPadding((16 * dp).toInt(), (16 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt())
minimumHeight = screenHeight
}
// Title row
val titleRow = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
val title = TextView(context).apply {
text = context.getString(R.string.snippets)
setTextColor(Color.WHITE)
textSize = 18f
typeface = Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
titleRow.addView(title)
root.addView(titleRow)
// Forward references for adapter refresh from create form
var filteredSnippets = snippets.toList()
var adapter: ArrayAdapter<Snippet>? = null
var emptyText: TextView? = null
// --- Inline create form (hidden by default) ---
val createForm = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.parseColor("#2A2A3E"))
val pad = (12 * dp).toInt()
setPadding(pad, pad, pad, pad)
visibility = View.GONE
}
val contentEdit = EditText(context).apply {
hint = context.getString(R.string.snippet_command_hint)
setHintTextColor(Color.parseColor("#BBBBBB"))
setTextColor(Color.WHITE)
textSize = 14f
typeface = Typeface.MONOSPACE
setBackgroundColor(Color.parseColor("#1E1E2E"))
setPadding((8 * dp).toInt(), (8 * dp).toInt(), (8 * dp).toInt(), (8 * dp).toInt())
minLines = 2
}
createForm.addView(contentEdit, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT
))
val nameEdit = EditText(context).apply {
hint = context.getString(R.string.snippet_name_hint)
setHintTextColor(Color.parseColor("#BBBBBB"))
setTextColor(Color.WHITE)
textSize = 13f
setBackgroundColor(Color.parseColor("#1E1E2E"))
setPadding((8 * dp).toInt(), (6 * dp).toInt(), (8 * dp).toInt(), (6 * dp).toInt())
isSingleLine = true
}
createForm.addView(nameEdit, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = (6 * dp).toInt() })
// Per-host checkbox
val perHostCheck = android.widget.CheckBox(context).apply {
text = if (savedConnectionId > 0) context.getString(R.string.snippet_this_host_only)
else context.getString(R.string.snippet_global_no_host)
setTextColor(Color.parseColor("#AAAAAA"))
textSize = 12f
isChecked = savedConnectionId > 0
isEnabled = savedConnectionId > 0
}
createForm.addView(perHostCheck, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = (4 * dp).toInt() })
val saveRow = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.END
}
val saveBtn = MaterialButton(context).apply {
text = context.getString(R.string.save)
textSize = 12f
setOnClickListener {
val content = contentEdit.text?.toString()?.trim() ?: ""
if (content.isNotBlank()) {
val name = nameEdit.text?.toString()?.trim() ?: ""
val connId = if (perHostCheck.isChecked && savedConnectionId > 0) savedConnectionId else null
onSave(name, content, connId)
contentEdit.setText("")
nameEdit.setText("")
createForm.visibility = View.GONE
// Refresh the list
refreshSnippets { updated ->
filteredSnippets = updated
adapter?.clear()
adapter?.addAll(updated)
adapter?.notifyDataSetChanged()
emptyText?.visibility = if (updated.isEmpty()) View.VISIBLE else View.GONE
}
}
}
}
saveRow.addView(saveBtn)
createForm.addView(saveRow, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = (6 * dp).toInt() })
root.addView(createForm, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = (8 * dp).toInt() })
// + button toggles create form
val addBtn = MaterialButton(context, null, com.google.android.material.R.attr.materialIconButtonStyle).apply {
text = "+"
setTextColor(Color.parseColor("#55EFC4"))
textSize = 20f
setOnClickListener {
if (snippets.size >= maxSnippets) {
Toast.makeText(context, context.getString(R.string.snippet_limit_reached, maxSnippets), Toast.LENGTH_LONG).show()
} else {
createForm.visibility = if (createForm.visibility == View.VISIBLE) View.GONE else View.VISIBLE
if (createForm.visibility == View.VISIBLE) contentEdit.requestFocus()
}
}
}
titleRow.addView(addBtn)
// Search bar
val searchInput = EditText(context).apply {
hint = context.getString(R.string.snippet_search_hint)
setHintTextColor(Color.parseColor("#BBBBBB"))
setTextColor(Color.WHITE)
textSize = 14f
setBackgroundColor(Color.parseColor("#2A2A3E"))
setPadding((12 * dp).toInt(), (8 * dp).toInt(), (12 * dp).toInt(), (8 * dp).toInt())
}
root.addView(searchInput, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = (8 * dp).toInt() })
// List
val listView = ListView(context).apply {
setBackgroundColor(Color.TRANSPARENT)
dividerHeight = 0
}
root.addView(listView, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
).apply { topMargin = (8 * dp).toInt() })
// Adapter
adapter = object : ArrayAdapter<Snippet>(context, 0, filteredSnippets.toMutableList()) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val snippet = getItem(position) ?: return View(context)
val row = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding((12 * dp).toInt(), (10 * dp).toInt(), (12 * dp).toInt(), (10 * dp).toInt())
}
// Name + scope badge row
val nameRow = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
val nameText = TextView(context).apply {
text = snippet.name
setTextColor(Color.parseColor("#55EFC4"))
textSize = 14f
typeface = Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
nameRow.addView(nameText)
val isLocal = snippet.connectionId != null
val badge = TextView(context).apply {
text = if (isLocal) context.getString(R.string.snippet_scope_local)
else context.getString(R.string.snippet_scope_global)
setTextColor(if (isLocal) Color.parseColor("#FFB020") else Color.parseColor("#888888"))
textSize = 10f
typeface = Typeface.DEFAULT_BOLD
}
nameRow.addView(badge)
val contentText = TextView(context).apply {
text = if (snippet.content.length > 80) snippet.content.take(80) + "..." else snippet.content
setTextColor(Color.parseColor("#AAAAAA"))
textSize = 12f
typeface = Typeface.MONOSPACE
maxLines = 2
}
row.addView(nameRow)
row.addView(contentText)
return row
}
}
listView.adapter = adapter
// Search filter
searchInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
val query = s?.toString()?.lowercase() ?: ""
filteredSnippets = if (query.isEmpty()) snippets
else snippets.filter {
it.name.lowercase().contains(query) || it.content.lowercase().contains(query)
}
adapter?.clear()
adapter?.addAll(filteredSnippets)
adapter?.notifyDataSetChanged()
}
})
// Tap → send (execute with enter)
listView.setOnItemClickListener { _, _, pos, _ ->
val snippet = filteredSnippets.getOrNull(pos) ?: return@setOnItemClickListener
dialog.dismiss()
onSendNow(snippet)
}
// Long tap → options
listView.setOnItemLongClickListener { _, _, pos, _ ->
val snippet = filteredSnippets.getOrNull(pos) ?: return@setOnItemLongClickListener true
MaterialAlertDialogBuilder(context)
.setTitle(snippet.name)
.setItems(arrayOf(
context.getString(R.string.snippet_insert_no_enter),
context.getString(R.string.edit),
context.getString(R.string.snippet_duplicate),
context.getString(R.string.delete)
)) { _, which ->
val doRefresh = {
refreshSnippets { updated ->
filteredSnippets = updated
adapter?.clear()
adapter?.addAll(updated)
emptyText?.visibility = if (updated.isEmpty()) View.VISIBLE else View.GONE
// Inline create form
if (showCreateForm) {
Spacer(Modifier.height(8.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(8.dp)
)
.padding(12.dp)
) {
OutlinedTextField(
value = createContent,
onValueChange = { createContent = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(stringResource(R.string.snippet_command_hint)) },
minLines = 2,
textStyle = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Teal,
cursorColor = Teal
)
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = createName,
onValueChange = { createName = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(stringResource(R.string.snippet_name_hint)) },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Teal,
cursorColor = Teal
)
)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = createPerHost,
onCheckedChange = { if (savedConnectionId > 0) createPerHost = it },
enabled = savedConnectionId > 0,
colors = CheckboxDefaults.colors(checkedColor = Teal)
)
Text(
text = if (savedConnectionId > 0) stringResource(R.string.snippet_this_host_only)
else stringResource(R.string.snippet_global_no_host),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Button(
onClick = {
val content = createContent.trim()
if (content.isNotBlank()) {
val connId = if (createPerHost && savedConnectionId > 0) savedConnectionId else null
onSave(createName.trim(), content, connId)
createContent = ""
createName = ""
showCreateForm = false
}
}
) {
Text(stringResource(R.string.save))
}
}
}
when (which) {
0 -> { dialog.dismiss(); onInsert(snippet) }
1 -> onEdit(snippet)
2 -> onDuplicate(snippet)
3 -> { onDelete(snippet); doRefresh() }
}
Spacer(Modifier.height(8.dp))
// Snippet list
if (filteredSnippets.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.snippet_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(filteredSnippets, key = { it.id }) { snippet ->
SnippetItem(
snippet = snippet,
onTap = {
onSendNow(snippet)
onDismiss()
},
onInsert = {
onInsert(snippet)
onDismiss()
},
onEdit = {
editingSnippet = snippet
},
onDuplicate = {
onDuplicate(snippet) { created ->
editingSnippet = created
}
},
onDelete = { onDelete(snippet) }
)
}
}
}
.show()
true
}
}
emptyText = if (snippets.isEmpty()) {
TextView(context).apply {
text = context.getString(R.string.snippet_empty)
setTextColor(Color.parseColor("#BBBBBB"))
textSize = 14f
gravity = Gravity.CENTER
setPadding(0, (32 * dp).toInt(), 0, 0)
}.also { root.addView(it) }
} else null
dialog.setContentView(root)
dialog.behavior.state = com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
dialog.behavior.skipCollapsed = true
dialog.behavior.peekHeight = context.resources.displayMetrics.heightPixels
// Keyboard overlays on top — don't push or resize the screen
dialog.window?.setSoftInputMode(
android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN or
android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
)
dialog.show()
}
/** Dialog to edit an existing snippet */
fun showEditSnippet(
context: Context,
snippet: Snippet,
savedConnectionId: Long = 0L,
onSave: (name: String, content: String, connectionId: Long?) -> Unit
) {
val dp = context.resources.displayMetrics.density
val layout = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
}
val nameInput = TextInputLayout(context, null, com.google.android.material.R.attr.textInputOutlinedStyle).apply {
hint = context.getString(R.string.snippet_name_label)
}
val nameEdit = TextInputEditText(context).apply { setText(snippet.name) }
nameInput.addView(nameEdit)
layout.addView(nameInput)
val contentInput = TextInputLayout(context, null, com.google.android.material.R.attr.textInputOutlinedStyle).apply {
hint = context.getString(R.string.snippet_content_label)
}
val contentEdit = TextInputEditText(context).apply {
setText(snippet.content)
minLines = 3
typeface = Typeface.MONOSPACE
}
contentInput.addView(contentEdit)
layout.addView(contentInput, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = (8 * dp).toInt() })
// Per-host checkbox
val perHostCheck = android.widget.CheckBox(context).apply {
text = if (savedConnectionId > 0) context.getString(R.string.snippet_this_host_only)
else context.getString(R.string.snippet_global_no_host)
isChecked = snippet.connectionId != null
isEnabled = savedConnectionId > 0
}
layout.addView(perHostCheck, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = (8 * dp).toInt() })
MaterialAlertDialogBuilder(context)
.setTitle(context.getString(R.string.snippet_edit_title))
.setView(layout)
.setPositiveButton(R.string.save) { _, _ ->
val name = nameEdit.text?.toString()?.trim() ?: ""
val content = contentEdit.text?.toString() ?: ""
if (name.isNotBlank() && content.isNotBlank()) {
val connId = if (perHostCheck.isChecked && savedConnectionId > 0) savedConnectionId else null
onSave(name, content, connId)
}
}
.setNegativeButton(R.string.cancel, null)
.show()
// Edit snippet dialog (triggered from long-press Edit)
editingSnippet?.let { snippet ->
EditSnippetDialog(
snippet = snippet,
savedConnectionId = savedConnectionId,
onSave = { name, content, connId ->
onUpdate(snippet.copy(name = name, content = content, connectionId = connId))
editingSnippet = null
},
onDismiss = { editingSnippet = null }
)
}
}
/**
* A single snippet item row with long-press context menu.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SnippetItem(
snippet: Snippet,
onTap: () -> Unit,
onInsert: () -> Unit,
onEdit: () -> Unit,
onDuplicate: () -> Unit,
onDelete: () -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onTap,
onLongClick = { showMenu = true }
)
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
Column {
// Name + scope badge row
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = snippet.name,
color = Teal,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.width(8.dp))
val isLocal = snippet.connectionId != null
Text(
text = if (isLocal) stringResource(R.string.snippet_scope_local)
else stringResource(R.string.snippet_scope_global),
color = if (isLocal) ScopeBadgeLocal else ScopeBadgeGlobal,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
// Content preview
Text(
text = snippet.content,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
// Context menu (long-press)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.snippet_insert_no_enter)) },
onClick = { showMenu = false; onInsert() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.edit)) },
onClick = { showMenu = false; onEdit() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.snippet_duplicate)) },
onClick = { showMenu = false; onDuplicate() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.delete)) },
onClick = { showMenu = false; onDelete() }
)
}
}
}
/**
* Dialog to edit an existing snippet.
*/
@Composable
fun EditSnippetDialog(
snippet: Snippet,
savedConnectionId: Long,
onSave: (name: String, content: String, connectionId: Long?) -> Unit,
onDismiss: () -> Unit
) {
var name by remember(snippet.id) { mutableStateOf(snippet.name) }
var content by remember(snippet.id) { mutableStateOf(snippet.content) }
var perHost by remember(snippet.id) { mutableStateOf(snippet.connectionId != null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.snippet_edit_title)) },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.snippet_name_label)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Teal,
cursorColor = Teal
)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = content,
onValueChange = { content = it },
label = { Text(stringResource(R.string.snippet_content_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
textStyle = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Teal,
cursorColor = Teal
)
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = perHost,
onCheckedChange = { if (savedConnectionId > 0) perHost = it },
enabled = savedConnectionId > 0,
colors = CheckboxDefaults.colors(checkedColor = Teal)
)
Text(
text = if (savedConnectionId > 0) stringResource(R.string.snippet_this_host_only)
else stringResource(R.string.snippet_global_no_host),
style = MaterialTheme.typography.bodySmall
)
}
}
},
confirmButton = {
TextButton(
onClick = {
val n = name.trim()
val c = content.trim()
if (n.isNotBlank() && c.isNotBlank()) {
val connId = if (perHost && savedConnectionId > 0) savedConnectionId else null
onSave(n, c, connId)
}
}
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}

View file

@ -1,599 +1,382 @@
package com.roundingmobile.sshworkbench.terminal
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.text.InputType
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.ScrollView
import android.widget.Space
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roundingmobile.ssh.HostKeyAction
import com.roundingmobile.sshworkbench.R
/**
* Modern Material 3 styled dialog builders for the terminal screen.
*
* All dialogs use [MaterialAlertDialogBuilder] with custom-styled views
* that match the terminal dark theme.
*/
object TerminalDialogs {
/** Result from the auth prompt dialog. */
data class AuthResult(val responses: List<String>, val remember: Boolean)
// Terminal theme colors
private const val SURFACE = 0xFF16213E.toInt()
private const val SURFACE_VARIANT = 0xFF0F3460.toInt()
private const val ON_SURFACE = 0xFFE0E0E0.toInt()
private const val ON_SURFACE_VARIANT = 0xFFB0B0B0.toInt()
private const val PRIMARY = 0xFF55EFC4.toInt()
private const val ERROR = 0xFFFF6B6B.toInt()
private const val WARNING = 0xFFFFB74D.toInt()
private const val OUTLINE = 0xFF3A3A5C.toInt()
/** Result from the password dialog. */
data class PasswordResult(val password: String, val remember: Boolean)
private fun Int.dp(ctx: Context): Int = (this * ctx.resources.displayMetrics.density).toInt()
/** Requests that bridge imperative service callbacks to declarative Compose dialogs. */
sealed interface TerminalDialogRequest {
data class HostKey(
val host: String,
val port: Int,
val keyType: String,
val fingerprint: String,
val storedFingerprint: String?,
val onResult: (HostKeyAction) -> Unit
) : TerminalDialogRequest
// ========================================================================
// Clean exit dialog
// ========================================================================
data class AuthPrompt(
val instruction: String,
val prompts: List<Pair<String, Boolean>>,
val showRemember: Boolean,
val onResult: (AuthResult?) -> Unit
) : TerminalDialogRequest
fun showCleanExit(
context: Context,
onClose: () -> Unit,
onStay: () -> Unit
) {
val layout = buildDialogContent(context) {
addBody(it, context.getString(R.string.dialog_session_ended_message))
}
data class PasswordPrompt(
val host: String,
val username: String,
val errorMessage: String?,
val onConnect: (PasswordResult) -> Unit,
val onDisconnect: () -> Unit
) : TerminalDialogRequest
}
MaterialAlertDialogBuilder(context)
.setIcon(tintedIcon(context, android.R.drawable.ic_menu_close_clear_cancel, PRIMARY))
.setTitle(context.getString(R.string.dialog_session_ended_title))
.setView(layout)
.setPositiveButton(context.getString(R.string.close)) { _, _ -> onClose() }
.setNegativeButton(context.getString(R.string.dialog_stay)) { _, _ -> onStay() }
.setCancelable(false)
.show()
}
// Warning color — not in the Material theme, defined locally
private val Warning = Color(0xFFFFB74D)
// ========================================================================
// Host key verification dialog
// ========================================================================
// ============================================================================
// Host key verification dialog (TOFU)
// ============================================================================
fun showHostKey(
context: Context,
host: String,
port: Int,
keyType: String,
fingerprint: String,
storedFingerprint: String?,
onResult: (HostKeyAction) -> Unit
) {
val isChanged = storedFingerprint != null
@Composable
fun HostKeyDialog(
request: TerminalDialogRequest.HostKey,
onDismiss: () -> Unit
) {
val isChanged = request.storedFingerprint != null
val layout = buildDialogContent(context) { root ->
if (isChanged) {
addBody(root, context.getString(R.string.dialog_host_key_changed_message))
addSpacer(root, 12)
addLabel(root, context.getString(R.string.dialog_label_server))
addMonoBox(root, "$host:$port")
addSpacer(root, 8)
addLabel(root, context.getString(R.string.dialog_label_prev_fingerprint))
addMonoBox(root, requireNotNull(storedFingerprint), ERROR)
addSpacer(root, 8)
addLabel(root, context.getString(R.string.dialog_label_new_fingerprint))
addMonoBox(root, fingerprint, WARNING)
} else {
addBody(root, context.getString(R.string.dialog_host_key_new_message))
addSpacer(root, 12)
addLabel(root, context.getString(R.string.dialog_label_server))
addMonoBox(root, "$host:$port")
addSpacer(root, 8)
addLabel(root, context.getString(R.string.dialog_label_key_type))
addMonoBox(root, keyType)
addSpacer(root, 8)
addLabel(root, context.getString(R.string.dialog_label_fingerprint))
addMonoBox(root, fingerprint, PRIMARY)
}
}
val title = if (isChanged) context.getString(R.string.dialog_host_key_changed_title) else context.getString(R.string.dialog_host_key_new_title)
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(context.getString(R.string.dialog_accept)) { _, _ -> onResult(HostKeyAction.ACCEPT) }
.setNegativeButton(context.getString(R.string.dialog_reject)) { _, _ -> onResult(HostKeyAction.REJECT) }
.setCancelable(false)
.show()
}
// ========================================================================
// Auth prompt dialog
// ========================================================================
data class AuthResult(val responses: List<String>, val remember: Boolean)
fun showAuthPrompt(
context: Context,
instruction: String,
prompts: List<Pair<String, Boolean>>,
showRemember: Boolean,
onResult: (AuthResult?) -> Unit
) {
val inputLayouts = mutableListOf<TextInputLayout>()
val layout = buildDialogContent(context) { root ->
if (instruction.isNotBlank()) {
addBody(root, instruction)
addSpacer(root, 8)
}
for ((promptText, echo) in prompts) {
val isPassword = !echo || promptText.lowercase().contains("password")
val til = TextInputLayout(context, null,
com.google.android.material.R.attr.textInputOutlinedStyle
).apply {
hint = promptText.trim()
if (isPassword) {
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
}
boxStrokeColor = PRIMARY
setHintTextColor(android.content.res.ColorStateList.valueOf(ON_SURFACE_VARIANT))
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = 8.dp(context) }
}
val edit = TextInputEditText(context).apply {
inputType = if (isPassword) {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
} else {
InputType.TYPE_CLASS_TEXT
}
setTextColor(ON_SURFACE)
setSingleLine()
}
til.addView(edit)
root.addView(til)
inputLayouts.add(til)
}
}
var rememberBox: CheckBox? = null
if (showRemember) {
rememberBox = CheckBox(context).apply {
text = context.getString(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)
})
}
MaterialAlertDialogBuilder(context)
.setIcon(tintedIcon(context, android.R.drawable.ic_lock_idle_lock, PRIMARY))
.setTitle(context.getString(R.string.dialog_server_auth_title))
.setView(layout)
.setPositiveButton(context.getString(R.string.ok)) { _, _ ->
val responses = inputLayouts.map {
(it.editText as? TextInputEditText)?.text?.toString() ?: ""
}
onResult(AuthResult(responses, rememberBox?.isChecked == true))
}
.setNegativeButton(context.getString(R.string.cancel)) { _, _ ->
onResult(null)
}
.setCancelable(false)
.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
// ========================================================================
data class ConnectFields(
val hostInput: TextInputLayout,
val portInput: TextInputLayout,
val userInput: TextInputLayout,
val passInput: TextInputLayout,
val savedList: ListView,
val savedLabel: TextView
)
fun buildConnectDialog(
context: Context,
devDefaults: Boolean,
onConnect: (host: String, port: Int, username: String, password: String) -> Unit,
onLocalShell: () -> Unit,
onCancel: () -> Unit
): Pair<androidx.appcompat.app.AlertDialog, ConnectFields> {
val pad = 24.dp(context)
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, 16.dp(context), pad, 8.dp(context))
}
// Saved connections section
val savedLabel = TextView(context).apply {
text = context.getString(R.string.dialog_saved_connections)
textSize = 11f
setTextColor(ON_SURFACE_VARIANT)
typeface = Typeface.DEFAULT_BOLD
letterSpacing = 0.08f
setPadding(4.dp(context), 0, 0, 4.dp(context))
}
root.addView(savedLabel)
val listView = ListView(context).apply {
divider = GradientDrawable().apply {
setColor(OUTLINE)
setSize(0, 1)
}
setBackgroundColor(Color.TRANSPARENT)
}
root.addView(listView, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 120.dp(context)
))
// Divider
root.addView(View(context).apply {
setBackgroundColor(OUTLINE)
}, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 1
).apply { topMargin = 12.dp(context); bottomMargin = 12.dp(context) })
// Manual entry section
val manualLabel = TextView(context).apply {
text = context.getString(R.string.dialog_manual_connection)
textSize = 11f
setTextColor(ON_SURFACE_VARIANT)
typeface = Typeface.DEFAULT_BOLD
letterSpacing = 0.08f
setPadding(4.dp(context), 0, 0, 8.dp(context))
}
root.addView(manualLabel)
fun makeInputLayout(hint: String, inputType: Int, defaultText: String = ""): TextInputLayout {
val til = TextInputLayout(context, null,
com.google.android.material.R.attr.textInputOutlinedStyle
).apply {
this.hint = hint
boxStrokeColor = PRIMARY
setHintTextColor(android.content.res.ColorStateList.valueOf(ON_SURFACE_VARIANT))
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = 4.dp(context) }
}
val edit = TextInputEditText(context).apply {
this.inputType = inputType
setTextColor(ON_SURFACE)
setSingleLine()
if (defaultText.isNotEmpty()) setText(defaultText)
}
til.addView(edit)
return til
}
val hostInput = makeInputLayout(context.getString(R.string.host), InputType.TYPE_CLASS_TEXT,
if (devDefaults) "10.10.0.39" else "")
val portInput = makeInputLayout(context.getString(R.string.port), InputType.TYPE_CLASS_NUMBER, "22")
val userInput = makeInputLayout(context.getString(R.string.username), InputType.TYPE_CLASS_TEXT,
if (devDefaults) "office" else "")
val passInput = makeInputLayout(context.getString(R.string.password),
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD).apply {
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
}
root.addView(hostInput)
root.addView(portInput)
root.addView(userInput)
root.addView(passInput)
val scrollView = ScrollView(context).apply {
addView(root)
}
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(context.getString(R.string.dialog_ssh_connect_title))
.setView(scrollView)
.setPositiveButton(context.getString(R.string.connect)) { _, _ ->
val host = (hostInput.editText as? TextInputEditText)?.text?.toString()?.trim() ?: ""
val port = (portInput.editText as? TextInputEditText)?.text?.toString()?.toIntOrNull() ?: 22
val user = (userInput.editText as? TextInputEditText)?.text?.toString()?.trim() ?: ""
val pass = (passInput.editText as? TextInputEditText)?.text?.toString() ?: ""
if (host.isNotBlank() && user.isNotBlank()) {
onConnect(host, port, user, pass)
}
}
.setNeutralButton(context.getString(R.string.dialog_local_shell)) { _, _ -> onLocalShell() }
.setNegativeButton(context.getString(R.string.cancel)) { _, _ -> onCancel() }
.create()
val fields = ConnectFields(hostInput, portInput, userInput, passInput, listView, savedLabel)
return Pair(dialog, fields)
}
// ========================================================================
// Saved connection context menu
// ========================================================================
fun showConnectionActions(
context: Context,
label: String,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
MaterialAlertDialogBuilder(context)
.setTitle(label)
.setItems(arrayOf(context.getString(R.string.edit), context.getString(R.string.delete))) { _, which ->
when (which) {
0 -> onEdit()
1 -> onDelete()
}
}
.show()
}
// ========================================================================
// URL actions dialog
// ========================================================================
fun showUrlActions(
context: Context,
url: String,
onOpen: () -> Unit,
onCopy: () -> Unit
) {
val content = buildDialogContent(context) { root ->
addLabel(root, context.getString(R.string.dialog_detected_url))
addMonoBox(root, url, PRIMARY)
}
MaterialAlertDialogBuilder(context)
.setView(content)
.setPositiveButton(context.getString(R.string.dialog_open)) { _, _ -> onOpen() }
.setNeutralButton(context.getString(R.string.dialog_copy)) { _, _ -> onCopy() }
.setNegativeButton(context.getString(R.string.cancel), null)
.show()
}
// ========================================================================
// Save session buffer dialog
// ========================================================================
fun showSaveBuffer(
context: Context,
suggestedName: String,
onSave: (fileName: String) -> Unit
) {
val inputLayout = TextInputLayout(context, null,
com.google.android.material.R.attr.textInputOutlinedStyle
).apply {
hint = context.getString(R.string.dialog_file_name)
boxStrokeColor = PRIMARY
setHintTextColor(android.content.res.ColorStateList.valueOf(ON_SURFACE_VARIANT))
setPadding(24.dp(context), 8.dp(context), 24.dp(context), 16.dp(context))
}
val editText = TextInputEditText(context).apply {
setText(suggestedName)
setTextColor(ON_SURFACE)
setSingleLine()
inputType = InputType.TYPE_CLASS_TEXT
// Select the name part before .txt for easy editing
post {
val dotIdx = suggestedName.lastIndexOf('.')
if (dotIdx > 0) setSelection(0, dotIdx) else selectAll()
}
}
inputLayout.addView(editText)
MaterialAlertDialogBuilder(context)
.setTitle(context.getString(R.string.dialog_save_session_title))
.setView(inputLayout)
.setPositiveButton(context.getString(R.string.save)) { _, _ ->
val name = editText.text?.toString()?.trim()
if (!name.isNullOrBlank()) {
val finalName = if (name.contains('.')) name else "$name.txt"
onSave(finalName)
}
}
.setNegativeButton(context.getString(R.string.cancel), null)
.show()
}
// ========================================================================
// Layout helpers
// ========================================================================
private fun buildDialogContent(context: Context, block: (LinearLayout) -> Unit): View {
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(24.dp(context), 8.dp(context), 24.dp(context), 16.dp(context))
}
block(root)
return root
}
/** Create a tinted drawable for use with MaterialAlertDialogBuilder.setIcon() */
private fun tintedIcon(context: Context, resId: Int, tint: Int): android.graphics.drawable.Drawable {
val drawable = androidx.core.content.ContextCompat.getDrawable(context, resId)!!.mutate()
androidx.core.graphics.drawable.DrawableCompat.setTint(drawable, tint)
return drawable
}
private fun addBody(root: LinearLayout, text: String) {
val ctx = root.context
val tv = TextView(ctx).apply {
this.text = text
textSize = 14f
setTextColor(ON_SURFACE)
setLineSpacing(4f, 1f)
}
root.addView(tv)
}
private fun addLabel(root: LinearLayout, text: String) {
val ctx = root.context
val tv = TextView(ctx).apply {
this.text = text
textSize = 11f
setTextColor(ON_SURFACE_VARIANT)
typeface = Typeface.DEFAULT_BOLD
letterSpacing = 0.08f
setPadding(0, 0, 0, 4.dp(ctx))
}
root.addView(tv)
}
private fun addMonoBox(root: LinearLayout, text: String, accentColor: Int = ON_SURFACE) {
val ctx = root.context
val bg = GradientDrawable().apply {
setColor(SURFACE_VARIANT)
cornerRadius = 8.dp(ctx).toFloat()
setStroke(1, OUTLINE)
}
val tv = TextView(ctx).apply {
this.text = text
textSize = 12f
typeface = Typeface.MONOSPACE
setTextColor(accentColor)
setPadding(12.dp(ctx), 10.dp(ctx), 12.dp(ctx), 10.dp(ctx))
background = bg
setTextIsSelectable(true)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
AlertDialog(
onDismissRequest = { /* non-cancellable */ },
title = {
Text(
text = if (isChanged) stringResource(R.string.dialog_host_key_changed_title)
else stringResource(R.string.dialog_host_key_new_title)
)
}
root.addView(tv)
}
},
text = {
Column {
if (isChanged) {
Text(
text = stringResource(R.string.dialog_host_key_changed_message),
color = MaterialTheme.colorScheme.onSurface,
fontSize = 14.sp,
lineHeight = 20.sp
)
Spacer(Modifier.height(12.dp))
MonoLabel(stringResource(R.string.dialog_label_server))
MonoBox(text = "${request.host}:${request.port}")
Spacer(Modifier.height(8.dp))
MonoLabel(stringResource(R.string.dialog_label_prev_fingerprint))
MonoBox(
text = requireNotNull(request.storedFingerprint),
accentColor = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(8.dp))
MonoLabel(stringResource(R.string.dialog_label_new_fingerprint))
MonoBox(text = request.fingerprint, accentColor = Warning)
} else {
Text(
text = stringResource(R.string.dialog_host_key_new_message),
color = MaterialTheme.colorScheme.onSurface,
fontSize = 14.sp,
lineHeight = 20.sp
)
Spacer(Modifier.height(12.dp))
MonoLabel(stringResource(R.string.dialog_label_server))
MonoBox(text = "${request.host}:${request.port}")
Spacer(Modifier.height(8.dp))
MonoLabel(stringResource(R.string.dialog_label_key_type))
MonoBox(text = request.keyType)
Spacer(Modifier.height(8.dp))
MonoLabel(stringResource(R.string.dialog_label_fingerprint))
MonoBox(
text = request.fingerprint,
accentColor = MaterialTheme.colorScheme.primary
)
}
}
},
confirmButton = {
TextButton(onClick = {
request.onResult(HostKeyAction.ACCEPT)
onDismiss()
}) {
Text(stringResource(R.string.dialog_accept))
}
},
dismissButton = {
TextButton(onClick = {
request.onResult(HostKeyAction.REJECT)
onDismiss()
}) {
Text(stringResource(R.string.dialog_reject))
}
},
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = if (isChanged) Warning else MaterialTheme.colorScheme.primary,
textContentColor = MaterialTheme.colorScheme.onSurface
)
}
private fun addSpacer(root: LinearLayout, heightDp: Int) {
val ctx = root.context
root.addView(Space(ctx), LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, heightDp.dp(ctx)
))
// ============================================================================
// Auth prompt dialog (keyboard-interactive)
// ============================================================================
@Composable
fun AuthPromptDialog(
request: TerminalDialogRequest.AuthPrompt,
onDismiss: () -> Unit
) {
val responses = remember { mutableStateListOf(*Array(request.prompts.size) { "" }) }
var rememberChecked by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { /* non-cancellable */ },
title = {
Text(stringResource(R.string.dialog_server_auth_title))
},
text = {
Column {
if (request.instruction.isNotBlank()) {
Text(
text = request.instruction,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 14.sp,
lineHeight = 20.sp
)
Spacer(Modifier.height(8.dp))
}
request.prompts.forEachIndexed { index, (promptText, echo) ->
val isPassword = !echo || promptText.lowercase().contains("password")
OutlinedTextField(
value = responses[index],
onValueChange = { responses[index] = it },
label = { Text(promptText.trim()) },
singleLine = true,
visualTransformation = if (isPassword) PasswordVisualTransformation()
else VisualTransformation.None,
keyboardOptions = if (isPassword) KeyboardOptions(keyboardType = KeyboardType.Password)
else KeyboardOptions.Default,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
focusedLabelColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
}
if (request.showRemember) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 4.dp)
) {
Checkbox(
checked = rememberChecked,
onCheckedChange = { rememberChecked = it },
colors = CheckboxDefaults.colors(
checkedColor = MaterialTheme.colorScheme.primary,
checkmarkColor = MaterialTheme.colorScheme.onPrimary
)
)
Text(
text = stringResource(R.string.remember_password),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp
)
}
}
}
},
confirmButton = {
TextButton(onClick = {
request.onResult(AuthResult(responses.toList(), rememberChecked))
onDismiss()
}) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = {
request.onResult(null)
onDismiss()
}) {
Text(stringResource(R.string.cancel))
}
},
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.primary,
textContentColor = MaterialTheme.colorScheme.onSurface
)
}
// ============================================================================
// Password prompt dialog (auth failed / no stored password)
// ============================================================================
@Composable
fun PasswordDialog(
request: TerminalDialogRequest.PasswordPrompt,
onDismiss: () -> Unit
) {
var password by remember { mutableStateOf("") }
var rememberChecked by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { /* non-cancellable */ },
title = {
Text(stringResource(R.string.password))
},
text = {
Column {
if (request.errorMessage != null) {
Text(
text = request.errorMessage,
color = MaterialTheme.colorScheme.error,
fontSize = 13.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Text(
text = "${request.username}@${request.host}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 12.dp)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
focusedLabelColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary
),
modifier = Modifier.fillMaxWidth()
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp)
) {
Checkbox(
checked = rememberChecked,
onCheckedChange = { rememberChecked = it },
colors = CheckboxDefaults.colors(
checkedColor = MaterialTheme.colorScheme.primary,
checkmarkColor = MaterialTheme.colorScheme.onPrimary
)
)
Text(
text = stringResource(R.string.remember_password),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp
)
}
}
},
confirmButton = {
TextButton(
onClick = {
request.onConnect(PasswordResult(password, rememberChecked))
onDismiss()
},
enabled = password.isNotEmpty()
) {
Text(stringResource(R.string.connect))
}
},
dismissButton = {
TextButton(onClick = {
request.onDisconnect()
onDismiss()
}) {
Text(stringResource(R.string.disconnect))
}
},
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.primary,
textContentColor = MaterialTheme.colorScheme.onSurface
)
}
// ============================================================================
// Shared composable helpers
// ============================================================================
@Composable
private fun MonoLabel(text: String) {
Text(
text = text,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.8.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp)
)
}
@Composable
private fun MonoBox(
text: String,
accentColor: Color = MaterialTheme.colorScheme.onSurface
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = accentColor,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)
)
}
}

View file

@ -149,6 +149,13 @@ class TerminalService : Service() {
*/
var authPromptHandler: (suspend (AuthPrompt) -> List<String>?)? = null
/**
* Callback for password prompt set by the Activity.
* Shown when all auth methods fail and the user needs to type a password.
* Returns PasswordResult (password + remember) or null to disconnect.
*/
var passwordPromptHandler: (suspend (host: String, username: String, errorMessage: String?) -> PasswordResult?)? = null
/** Callback for server banners during auth — set by the Activity */
var onBanner: ((String) -> Unit)? = null
@ -546,7 +553,26 @@ class TerminalService : Service() {
}
serviceScope.launch(Dispatchers.IO) {
val auth = helper.buildAuth(keyId, savedConnectionId, String(password))
var auth = helper.buildAuth(keyId, savedConnectionId, String(password))
// If no auth method available (no key, no stored password), prompt user
if (auth is SSHAuth.None) {
val promptHandler = passwordPromptHandler
if (promptHandler != null) {
FileLogger.log(TAG, "SSH[$sessionId] no stored auth — prompting for password")
val result = promptHandler(host, username, null)
if (result == null) {
// User chose disconnect
updateSessionState(sessionId, SessionState.Disconnected("User cancelled auth", cleanExit = true))
cleanupDisconnectedSession(sessionId)
return@launch
}
entry.password = result.password.toCharArray()
if (result.remember && savedConnectionId > 0) {
credentialStore.savePassword(savedConnectionId, result.password)
}
auth = SSHAuth.Password(result.password)
}
}
try {
// Log auth + jump info into SSHSession's debug buffer (visible in terminal on failure)
val authDesc = when (auth) {

View file

@ -1,14 +1,51 @@
package com.roundingmobile.sshworkbench.terminal
import android.content.Context
import android.graphics.*
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.widget.*
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.roundingmobile.sshworkbench.R
import com.roundingmobile.terminalview.TerminalTheme
@ -16,204 +53,122 @@ import com.roundingmobile.terminalview.TerminalTheme
* Full-screen theme picker with live terminal preview.
* Shows simulated `ls -l --color` output using each theme's actual ANSI colors.
*/
object ThemePickerDialog {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ThemePickerSheet(
currentThemeName: String,
onSave: (String) -> Unit,
onDismiss: () -> Unit
) {
val themes = remember { TerminalTheme.builtInThemes }
var selectedName by remember { mutableStateOf(currentThemeName) }
val selectedTheme = remember(selectedName) {
themes.find { it.name == selectedName } ?: themes[0]
}
fun show(
context: Context,
currentThemeName: String,
onSave: (String) -> Unit
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
val dialog = BottomSheetDialog(context)
val dp = context.resources.displayMetrics.density
val themes = TerminalTheme.builtInThemes
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.theme)) },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
) {
TerminalPreview(
theme = selectedTheme,
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.clip(RoundedCornerShape(8.dp))
)
var selectedName = currentThemeName
var selectedTheme = themes.find { it.name == currentThemeName } ?: themes[0]
Spacer(Modifier.height(12.dp))
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * dp).toInt()
setPadding(pad, (12 * dp).toInt(), pad, pad)
}
// Title
root.addView(TextView(context).apply {
text = context.getString(R.string.theme)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
setTextColor(Color.WHITE)
typeface = Typeface.DEFAULT_BOLD
gravity = Gravity.CENTER
setPadding(0, 0, 0, (12 * dp).toInt())
})
// Terminal preview
val preview = TerminalPreviewCanvas(context).apply {
setTheme(selectedTheme)
}
root.addView(preview, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, (220 * dp).toInt()
).apply { bottomMargin = (12 * dp).toInt() })
// Theme list
val themeContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
}
val themeViews = mutableListOf<View>()
for (theme in themes) {
val row = makeThemeRow(context, dp, theme, theme.name == selectedName) {
selectedName = theme.name
selectedTheme = theme
preview.setTheme(theme)
preview.invalidate()
themeViews.forEachIndexed { i, v ->
updateThemeRowSelection(v, dp, themes[i].name == selectedName)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(themes, key = { it.name }) { theme ->
ThemeRow(
theme = theme,
selected = theme.name == selectedName,
onClick = {
selectedName = theme.name
onSave(theme.name)
}
)
}
}
// Auto-save on tap
onSave(theme.name)
}
themeViews.add(row)
themeContainer.addView(row)
}
val scroll = object : ScrollView(context) {
override fun onInterceptTouchEvent(ev: android.view.MotionEvent): Boolean {
// Always claim touch events so the BottomSheet doesn't steal scroll-up
parent?.requestDisallowInterceptTouchEvent(true)
return super.onInterceptTouchEvent(ev)
}
}.apply {
addView(themeContainer)
isNestedScrollingEnabled = false
}
root.addView(scroll, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
))
dialog.setContentView(root)
dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
dialog.behavior.skipCollapsed = true
dialog.show()
}
private fun makeThemeRow(ctx: Context, dp: Float, theme: TerminalTheme, selected: Boolean, onClick: () -> Unit): LinearLayout {
return LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val pad = (10 * dp).toInt()
setPadding(pad, (8 * dp).toInt(), pad, (8 * dp).toInt())
setBackgroundColor(if (selected) Color.parseColor("#2A2A4A") else Color.TRANSPARENT)
tag = "theme_row"
// Color swatch
addView(View(ctx).apply {
val size = (32 * dp).toInt()
layoutParams = LinearLayout.LayoutParams(size, size).apply { marginEnd = (12 * dp).toInt() }
setBackgroundColor(theme.background)
})
// Name + fg/bg preview colors
val info = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
info.addView(TextView(ctx).apply {
text = theme.name
setTextColor(Color.WHITE)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
typeface = Typeface.DEFAULT_BOLD
})
// Small color bar showing a few ANSI colors
val colorBar = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, (4 * dp).toInt(), 0, 0)
}
val sampleColors = intArrayOf(
theme.ansiColors[1], theme.ansiColors[2], theme.ansiColors[3],
theme.ansiColors[4], theme.ansiColors[5], theme.ansiColors[6]
)
for (c in sampleColors) {
colorBar.addView(View(ctx).apply {
setBackgroundColor(c)
val s = (12 * dp).toInt()
layoutParams = LinearLayout.LayoutParams(s, s).apply { marginEnd = (3 * dp).toInt() }
})
}
info.addView(colorBar)
addView(info)
// Selection indicator
if (selected) {
addView(TextView(ctx).apply {
text = ""
setTextColor(Color.parseColor("#55EFC4"))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
})
}
setOnClickListener { onClick() }
}
}
private fun updateThemeRowSelection(view: View, dp: Float, selected: Boolean) {
view.setBackgroundColor(if (selected) Color.parseColor("#2A2A4A") else Color.TRANSPARENT)
}
}
// region Terminal preview
/**
* Canvas view that renders a simulated terminal with `ls -l --color` output
* Simulated `ls -l --color` terminal output drawn with Compose Canvas
* using the theme's actual ANSI colors.
*/
private class TerminalPreviewCanvas(context: Context) : View(context) {
@Composable
private fun TerminalPreview(
theme: TerminalTheme,
modifier: Modifier = Modifier
) {
val lines = remember { previewLines() }
val bgColor = Color(theme.background)
val borderColor = Color(0xFF444444)
private var theme: TerminalTheme = TerminalTheme.defaultDark()
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = Typeface.MONOSPACE
}
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 2f
color = Color.parseColor("#444444")
}
fun setTheme(t: TerminalTheme) {
theme = t
invalidate()
}
// Simulated ls -l output lines: each is a list of (color_index, text) pairs
// -1 = default fg, 0-15 = ANSI color index
private data class Span(val colorIdx: Int, val text: String, val bold: Boolean = false)
private val lines = listOf(
listOf(Span(-1, "user@host"), Span(4, ":"), Span(4, "~", bold = true), Span(-1, "$ ls -l --color")),
listOf(Span(-1, "total 42")),
listOf(Span(4, "drwxr-xr-x", bold = true), Span(-1, " 2 user user 4096 Mar 27 "), Span(4, "Documents", bold = true)),
listOf(Span(4, "drwxr-xr-x", bold = true), Span(-1, " 5 user user 4096 Mar 27 "), Span(4, "projects", bold = true)),
listOf(Span(2, "-rwxr-xr-x"), Span(-1, " 1 user user 8192 Mar 27 "), Span(2, "deploy.sh", bold = true)),
listOf(Span(-1, "-rw-r--r-- 1 user user 2048 Mar 27 README.md")),
listOf(Span(-1, "-rw-r--r-- 1 user user 512 Mar 27 "), Span(1, "error.log")),
listOf(Span(6, "lrwxrwxrwx"), Span(-1, " 1 user user 12 Mar 27 "), Span(6, "link", bold = true), Span(-1, " -> target")),
listOf(Span(-1, "-rw-r--r-- 1 user user 15360 Mar 27 "), Span(3, "archive.tar.gz", bold = true)),
listOf(Span(-1, "user@host"), Span(4, ":"), Span(4, "~", bold = true), Span(-1, "$ "), Span(-1, "")),
)
override fun onDraw(canvas: Canvas) {
val radius = 8f
val pad = 8f
Canvas(modifier = modifier) {
val pad = 8.dp.toPx()
val radius = 8.dp.toPx()
// Background
bgPaint.color = theme.background
canvas.drawRoundRect(pad, pad, width - pad, height - pad, radius, radius, bgPaint)
canvas.drawRoundRect(pad, pad, width - pad, height - pad, radius, radius, borderPaint)
drawRoundRect(
color = bgColor,
topLeft = Offset(pad, pad),
size = Size(size.width - 2 * pad, size.height - 2 * pad),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius)
)
// Border
drawRoundRect(
color = borderColor,
topLeft = Offset(pad, pad),
size = Size(size.width - 2 * pad, size.height - 2 * pad),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
)
// Text
val fontSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 11f, resources.displayMetrics)
textPaint.textSize = fontSize
val lineHeight = fontSize * 1.4f
var y = pad + fontSize + 8f
val x0 = pad + 12f
// Text rendering via native Canvas + Paint for monospace text measurement
val nativeCanvas = drawContext.canvas.nativeCanvas
val textPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
typeface = android.graphics.Typeface.MONOSPACE
textSize = 11.sp.toPx()
}
val lineHeight = textPaint.textSize * 1.4f
var y = pad + textPaint.textSize + 8.dp.toPx()
val x0 = pad + 12.dp.toPx()
for (line in lines) {
var x = x0
@ -225,11 +180,93 @@ private class TerminalPreviewCanvas(context: Context) : View(context) {
}
textPaint.color = color
textPaint.isFakeBoldText = span.bold
canvas.drawText(span.text, x, y, textPaint)
nativeCanvas.drawText(span.text, x, y, textPaint)
x += textPaint.measureText(span.text)
}
y += lineHeight
if (y > height - pad) break
if (y > size.height - pad) break
}
}
}
private data class Span(val colorIdx: Int, val text: String, val bold: Boolean = false)
private fun previewLines(): List<List<Span>> = listOf(
listOf(Span(-1, "user@host"), Span(4, ":"), Span(4, "~", bold = true), Span(-1, "$ ls -l --color")),
listOf(Span(-1, "total 42")),
listOf(Span(4, "drwxr-xr-x", bold = true), Span(-1, " 2 user user 4096 Mar 27 "), Span(4, "Documents", bold = true)),
listOf(Span(4, "drwxr-xr-x", bold = true), Span(-1, " 5 user user 4096 Mar 27 "), Span(4, "projects", bold = true)),
listOf(Span(2, "-rwxr-xr-x"), Span(-1, " 1 user user 8192 Mar 27 "), Span(2, "deploy.sh", bold = true)),
listOf(Span(-1, "-rw-r--r-- 1 user user 2048 Mar 27 README.md")),
listOf(Span(-1, "-rw-r--r-- 1 user user 512 Mar 27 "), Span(1, "error.log")),
listOf(Span(6, "lrwxrwxrwx"), Span(-1, " 1 user user 12 Mar 27 "), Span(6, "link", bold = true), Span(-1, " -> target")),
listOf(Span(-1, "-rw-r--r-- 1 user user 15360 Mar 27 "), Span(3, "archive.tar.gz", bold = true)),
listOf(Span(-1, "user@host"), Span(4, ":"), Span(4, "~", bold = true), Span(-1, "$ "), Span(-1, "\u2588")),
)
// endregion
// region Theme row
@Composable
private fun ThemeRow(
theme: TerminalTheme,
selected: Boolean,
onClick: () -> Unit
) {
val selectedBg = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
val checkColor = MaterialTheme.colorScheme.primary
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(if (selected) selectedBg else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Background color swatch
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(theme.background))
)
Spacer(Modifier.width(12.dp))
// Name + ANSI color bar
Column(modifier = Modifier.weight(1f)) {
Text(
text = theme.name,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) {
val sampleIndices = intArrayOf(1, 2, 3, 4, 5, 6)
for (idx in sampleIndices) {
Box(
modifier = Modifier
.size(12.dp)
.background(Color(theme.ansiColors[idx]))
)
}
}
}
// Checkmark
if (selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = checkColor,
modifier = Modifier.size(24.dp)
)
}
}
}
// endregion

View file

@ -58,9 +58,12 @@ import com.roundingmobile.sshworkbench.R
import com.roundingmobile.sshworkbench.auth.BiometricAuthManager
import com.roundingmobile.sshworkbench.pro.ProFeatures
import com.roundingmobile.sshworkbench.data.local.Snippet
import com.roundingmobile.sshworkbench.terminal.KeyboardSettingsDialog
import com.roundingmobile.sshworkbench.terminal.SnippetDialogs
import com.roundingmobile.sshworkbench.terminal.TerminalDialogs
import com.roundingmobile.sshworkbench.terminal.KeyboardSettingsScreen
import com.roundingmobile.sshworkbench.terminal.SnippetPickerSheet
import com.roundingmobile.sshworkbench.terminal.AuthPromptDialog
import com.roundingmobile.sshworkbench.terminal.HostKeyDialog
import com.roundingmobile.sshworkbench.terminal.PasswordDialog
import com.roundingmobile.sshworkbench.terminal.TerminalDialogRequest
import com.roundingmobile.sshworkbench.terminal.TerminalService
import com.roundingmobile.sshworkbench.ui.navigation.Routes
import com.roundingmobile.sshworkbench.ui.navigation.SshWorkbenchNavGraph
@ -118,27 +121,31 @@ class MainActivity : AppCompatActivity() {
// Wire up host key verification dialog (TOFU)
svc.hostKeyPromptHandler = { host, port, keyType, fingerprint, stored ->
suspendCancellableCoroutine { cont ->
runOnUiThread {
TerminalDialogs.showHostKey(this@MainActivity, host, port, keyType, fingerprint, stored) { action ->
cont.resume(action)
}
}
mainViewModel.showDialog(TerminalDialogRequest.HostKey(
host, port, keyType, fingerprint, stored
) { action -> cont.resume(action) })
}
}
// Wire up keyboard-interactive auth dialog
svc.authPromptHandler = { prompt ->
suspendCancellableCoroutine { cont ->
runOnUiThread {
TerminalDialogs.showAuthPrompt(
this@MainActivity,
prompt.instruction,
prompt.prompts.map { it.prompt to it.echo },
showRemember = true
) { result ->
cont.resume(result?.responses)
}
}
mainViewModel.showDialog(TerminalDialogRequest.AuthPrompt(
prompt.instruction,
prompt.prompts.map { it.prompt to it.echo },
showRemember = true
) { result -> cont.resume(result?.responses) })
}
}
// Wire up password prompt dialog (no stored password / auth failed)
svc.passwordPromptHandler = { host, username, errorMessage ->
suspendCancellableCoroutine { cont ->
mainViewModel.showDialog(TerminalDialogRequest.PasswordPrompt(
host, username, errorMessage,
onConnect = { result -> cont.resume(result) },
onDisconnect = { cont.resume(null) }
))
}
}
@ -214,6 +221,7 @@ class MainActivity : AppCompatActivity() {
val sessionLabels by mainViewModel.sessionLabels.collectAsStateWithLifecycle()
val sessionThemes by mainViewModel.sessionThemes.collectAsStateWithLifecycle()
val pendingQrKey by mainViewModel.pendingQrKey.collectAsStateWithLifecycle()
val dialogRequest by mainViewModel.dialogRequest.collectAsStateWithLifecycle()
val navController = rememberNavController()
// Keyboard preferences (global, not per-session)
@ -224,11 +232,16 @@ class MainActivity : AppCompatActivity() {
val keyboardLanguage by mainViewModel.terminalPrefs.keyboardLanguage.collectAsStateWithLifecycle("en")
val showKeyHints by mainViewModel.terminalPrefs.showKeyHints.collectAsStateWithLifecycle(true)
val showPageIndicators by mainViewModel.terminalPrefs.showPageIndicators.collectAsStateWithLifecycle(true)
val quickBarSize by mainViewModel.terminalPrefs.quickBarSize.collectAsStateWithLifecycle(42)
val cqbSize by mainViewModel.terminalPrefs.quickBarSize.collectAsStateWithLifecycle(42)
val aqbSize by mainViewModel.terminalPrefs.aqbSize.collectAsStateWithLifecycle(42)
val isCustomKeyboard = keyboardType == "custom"
val quickBarSize = if (isCustomKeyboard) cqbSize else aqbSize
// Temporary hide state — tapping terminal or navigating to terminal resets it
var ckbHidden by remember { mutableStateOf(false) }
// Keyboard settings dialog state (opened from CKB gear menu)
var showKbSettings by remember { mutableStateOf(false) }
var showGearMenu by remember { mutableStateOf(false) }
// Resolve keyboard layout and language resource IDs
val layoutResId = when (keyboardLanguage) {
@ -272,24 +285,7 @@ class MainActivity : AppCompatActivity() {
// Wire snippet and settings callbacks (resolve active session dynamically)
LaunchedEffect(keyboard) {
keyboard.onSnippetsTap = { showSnippetPicker() }
keyboard.onSettingsTap = {
com.google.android.material.dialog.MaterialAlertDialogBuilder(this@MainActivity)
.setItems(arrayOf(
getString(R.string.hide_keyboard),
getString(R.string.keyboard_settings_title)
)) { _, which ->
when (which) {
0 -> { ckbHidden = true }
1 -> KeyboardSettingsDialog.show(
context = this@MainActivity,
currentSettings = mainViewModel.getCurrentKeyboardSettings(),
proFeatures = proFeatures,
onSave = { newSettings -> mainViewModel.saveKeyboardSettings(newSettings) }
)
}
}
.show()
}
keyboard.onSettingsTap = { showGearMenu = true }
}
// Update dynamic keyboard settings
@ -328,6 +324,36 @@ class MainActivity : AppCompatActivity() {
}
}
// CKB gear long-press menu (Hide Keyboard / Keyboard Settings)
if (showGearMenu) {
val hideLabel = getString(R.string.hide_keyboard)
val settingsLabel = getString(R.string.keyboard_settings_title)
androidx.compose.material3.AlertDialog(
onDismissRequest = { showGearMenu = false },
confirmButton = {},
text = {
Column {
androidx.compose.material3.TextButton(onClick = { showGearMenu = false; ckbHidden = true }) {
androidx.compose.material3.Text(hideLabel)
}
androidx.compose.material3.TextButton(onClick = { showGearMenu = false; showKbSettings = true }) {
androidx.compose.material3.Text(settingsLabel)
}
}
}
)
}
// Keyboard settings dialog (opened from CKB gear → Keyboard Settings)
if (showKbSettings) {
KeyboardSettingsScreen(
currentSettings = mainViewModel.getCurrentKeyboardSettings(),
proFeatures = proFeatures,
onSave = { newSettings -> mainViewModel.saveKeyboardSettings(newSettings) },
onDismiss = { showKbSettings = false }
)
}
Box(Modifier.fillMaxSize()) {
// --- Layer 1: Terminal surfaces + tab bar + shared keyboard ---
// Always in the tree — visibility toggled. Views survive navigation.
@ -540,10 +566,89 @@ class MainActivity : AppCompatActivity() {
)
}
}
// Terminal dialogs (host key, auth prompt) — driven by ViewModel state
dialogRequest?.let { request ->
when (request) {
is TerminalDialogRequest.HostKey -> HostKeyDialog(
request = request,
onDismiss = { mainViewModel.dismissDialog() }
)
is TerminalDialogRequest.AuthPrompt -> AuthPromptDialog(
request = request,
onDismiss = { mainViewModel.dismissDialog() }
)
is TerminalDialogRequest.PasswordPrompt -> PasswordDialog(
request = request,
onDismiss = { mainViewModel.dismissDialog() }
)
}
}
// Snippet picker — driven by ViewModel state
val snippetState by mainViewModel.snippetPickerState.collectAsStateWithLifecycle()
snippetState?.let { state ->
val activeId = appState.activeSessionId
SnippetPickerSheet(
snippets = state.snippets,
maxSnippets = state.maxSnippets,
savedConnectionId = state.savedConnectionId,
onInsert = { snippet ->
FileLogger.log(TAG, "snippet insert: '${snippet.name}' session=$activeId") // TRACE
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.recordUse(snippet.id)
}
mainViewModel.writeToSession(activeId, snippet.content.toByteArray())
},
onSendNow = { snippet ->
FileLogger.log(TAG, "snippet sendNow: '${snippet.name}' session=$activeId") // TRACE
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.recordUse(snippet.id)
}
mainViewModel.writeToSession(activeId, (snippet.content + "\n").toByteArray())
},
onSave = { name, content, snippetConnId ->
FileLogger.log(TAG, "snippet save: '$name' connId=$snippetConnId") // TRACE
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.insert(
Snippet(name = name.ifBlank { content.take(30) }, content = content, connectionId = snippetConnId)
)
refreshSnippets(state.savedConnectionId)
}
},
onUpdate = { snippet ->
FileLogger.log(TAG, "snippet update: '${snippet.name}'") // TRACE
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.update(snippet)
refreshSnippets(state.savedConnectionId)
}
},
onDelete = { snippet ->
FileLogger.log(TAG, "snippet delete: '${snippet.name}'") // TRACE
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.delete(snippet)
refreshSnippets(state.savedConnectionId)
}
},
onDuplicate = { snippet, onCreated ->
FileLogger.log(TAG, "snippet duplicate: '${snippet.name}'") // TRACE
val copy = snippet.copy(id = 0, name = "${snippet.name} (copy)")
lifecycleScope.launch(Dispatchers.IO) {
val newId = mainViewModel.snippetDao.insert(copy)
refreshSnippets(state.savedConnectionId)
val saved = copy.copy(id = newId)
launch(Dispatchers.Main) { onCreated(saved) }
}
},
onDismiss = { mainViewModel.dismissSnippetPicker() },
limitReachedMessage = getString(R.string.snippet_limit_reached, state.maxSnippets)
)
}
}
/**
* Show snippet picker for the currently active session.
* Loads snippets and sets ViewModel state the Compose dialog renders from there.
*/
private fun showSnippetPicker() {
val activeId = mainViewModel.appState.value.activeSessionId
@ -554,85 +659,16 @@ class MainActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
val snippets = mainViewModel.snippetDao.getAllOnce()
.filter { it.connectionId == null || it.connectionId == connId }
launch(Dispatchers.Main) {
// Refresh callback — set by SnippetDialogs, callable after any mutation
var doRefresh: (() -> Unit)? = null
mainViewModel.showSnippetPicker(snippets, proFeatures.maxSnippets(), connId)
}
}
SnippetDialogs.showSnippetPicker(
context = this@MainActivity,
snippets = snippets,
maxSnippets = proFeatures.maxSnippets(),
savedConnectionId = connId,
onInsert = { snippet ->
FileLogger.log(TAG, "snippet insert: '${snippet.name}' session=$activeId") // TRACE
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.recordUse(snippet.id)
}
mainViewModel.writeToSession(activeId, snippet.content.toByteArray())
},
onSendNow = { snippet ->
FileLogger.log(TAG, "snippet sendNow: '${snippet.name}' session=$activeId") // TRACE
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.recordUse(snippet.id)
}
mainViewModel.writeToSession(activeId, (snippet.content + "\n").toByteArray())
},
onEdit = { snippet ->
FileLogger.log(TAG, "snippet edit: '${snippet.name}'") // TRACE
SnippetDialogs.showEditSnippet(this@MainActivity, snippet, savedConnectionId = connId) { name, content, snippetConnId ->
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.update(snippet.copy(name = name, content = content, connectionId = snippetConnId))
kotlinx.coroutines.delay(100)
launch(Dispatchers.Main) { doRefresh?.invoke() }
}
}
},
onDelete = { snippet ->
FileLogger.log(TAG, "snippet delete: '${snippet.name}'") // TRACE
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.delete(snippet)
}
},
onDuplicate = { snippet ->
FileLogger.log(TAG, "snippet duplicate: '${snippet.name}'") // TRACE
val copy = snippet.copy(id = 0, name = "${snippet.name} (copy)")
lifecycleScope.launch(Dispatchers.IO) {
val newId = mainViewModel.snippetDao.insert(copy)
val saved = copy.copy(id = newId)
launch(Dispatchers.Main) {
SnippetDialogs.showEditSnippet(this@MainActivity, saved, savedConnectionId = connId) { name, content, snippetConnId ->
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.update(saved.copy(name = name, content = content, connectionId = snippetConnId))
kotlinx.coroutines.delay(100)
launch(Dispatchers.Main) { doRefresh?.invoke() }
}
}
}
}
},
onSave = { name, content, snippetConnId ->
FileLogger.log(TAG, "snippet save: '$name' connId=$snippetConnId") // TRACE
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.snippetDao.insert(
Snippet(name = name.ifBlank { content.take(30) }, content = content, connectionId = snippetConnId)
)
}
},
refreshSnippets = { callback ->
// Store refresh as a reusable function
val refresh: () -> Unit = {
lifecycleScope.launch(Dispatchers.IO) {
kotlinx.coroutines.delay(100)
val updated = mainViewModel.snippetDao.getAllOnce()
.filter { it.connectionId == null || it.connectionId == connId }
launch(Dispatchers.Main) { callback(updated) }
}
}
doRefresh = refresh
refresh()
}
)
}
/** Reload snippets from DB and refresh the picker state. */
private fun refreshSnippets(connId: Long) {
lifecycleScope.launch(Dispatchers.IO) {
val updated = mainViewModel.snippetDao.getAllOnce()
.filter { it.connectionId == null || it.connectionId == connId }
mainViewModel.refreshSnippetPicker(updated)
}
}
@ -784,9 +820,9 @@ class MainActivity : AppCompatActivity() {
if (text != null) {
mainViewModel.writeToSession(activeId, text.toByteArray(Charsets.UTF_8))
if (enter) {
mainViewModel.writeToSession(activeId, byteArrayOf(0x0D))
}
}
if (enter) {
mainViewModel.writeToSession(activeId, byteArrayOf(0x0D))
}
if (esc != null) {
mainViewModel.writeToSession(activeId, "\u001b$esc".toByteArray(Charsets.UTF_8))
@ -826,6 +862,15 @@ class MainActivity : AppCompatActivity() {
FileLogger.log("ADBReceiver", "cursor=$row,$col font=${fontSz}sp grid=${buf.cols}x${buf.rows} line=$marker")
}
// DEBUG: Clear saved password by connection name: --es clearPassword "SSHTest NoPw"
val clearPwName = intent.getStringExtra("clearPassword")
if (clearPwName != null) {
lifecycleScope.launch(Dispatchers.IO) {
val found = mainViewModel.clearPasswordByName(clearPwName)
if (!found) FileLogger.log("ADBReceiver", "clearPassword: '$clearPwName' not found")
}
}
// Dump full screen buffer to log: --ez dump true
val dump = intent.getBooleanExtra("dump", false)
if (dump) {

View file

@ -7,10 +7,12 @@ import com.roundingmobile.sshworkbench.data.CredentialStore
import com.roundingmobile.sshworkbench.data.TerminalPreferences
import com.roundingmobile.sshworkbench.data.local.SavedConnection
import com.roundingmobile.sshworkbench.data.local.SavedConnectionDao
import com.roundingmobile.sshworkbench.data.local.Snippet
import com.roundingmobile.sshworkbench.data.local.SnippetDao
import com.roundingmobile.sshworkbench.R
import com.roundingmobile.sshworkbench.di.StringResolver
import com.roundingmobile.sshworkbench.pro.ProFeatures
import com.roundingmobile.sshworkbench.terminal.TerminalDialogRequest
import com.roundingmobile.sshworkbench.terminal.TerminalService
import com.roundingmobile.sshworkbench.util.FileLogger
import dagger.hilt.android.lifecycle.HiltViewModel
@ -87,6 +89,30 @@ class MainViewModel @Inject constructor(
private val _disconnectEvent = MutableSharedFlow<String>(extraBufferCapacity = 5)
val disconnectEvent: SharedFlow<String> = _disconnectEvent.asSharedFlow()
// --- Dialog requests (imperative→Compose bridge) ---
private val _dialogRequest = MutableStateFlow<TerminalDialogRequest?>(null)
val dialogRequest: StateFlow<TerminalDialogRequest?> = _dialogRequest.asStateFlow()
fun showDialog(request: TerminalDialogRequest) { _dialogRequest.value = request }
fun dismissDialog() { _dialogRequest.value = null }
// --- Snippet picker state ---
data class SnippetPickerState(
val snippets: List<Snippet>,
val maxSnippets: Int,
val savedConnectionId: Long
)
private val _snippetPickerState = MutableStateFlow<SnippetPickerState?>(null)
val snippetPickerState: StateFlow<SnippetPickerState?> = _snippetPickerState.asStateFlow()
fun showSnippetPicker(snippets: List<Snippet>, maxSnippets: Int, savedConnectionId: Long) {
_snippetPickerState.value = SnippetPickerState(snippets, maxSnippets, savedConnectionId)
}
fun dismissSnippetPicker() { _snippetPickerState.value = null }
fun refreshSnippetPicker(snippets: List<Snippet>) {
_snippetPickerState.update { it?.copy(snippets = snippets) }
}
// --- Active sessions from TerminalService ---
private val _activeSessions = MutableStateFlow<Map<Long, SessionState>>(emptyMap())
/** Ordered session map — use this for UI iteration (tab order). */
@ -792,6 +818,17 @@ class MainViewModel @Inject constructor(
)
}
/** DEBUG: Clear saved password for a connection by name (used by ADB test runner). */
suspend fun clearPasswordByName(name: String): Boolean {
val conn = connectionDao.getAllOnce()
.find { it.name.equals(name, ignoreCase = true)
|| it.nickname?.equals(name, ignoreCase = true) == true }
?: return false
credentialStore.deletePassword(conn.id)
FileLogger.log(TAG, "clearPasswordByName: deleted for '${conn.name}' (id=${conn.id})")
return true
}
fun saveKeyboardSettings(settings: com.roundingmobile.sshworkbench.terminal.KeyboardDisplaySettings) {
FileLogger.log(TAG, "saveKeyboardSettings: lang=${settings.language} height=${settings.heightPercent}") // TRACE
viewModelScope.launch {

View file

@ -61,7 +61,7 @@ import com.roundingmobile.sshworkbench.auth.BiometricAuthManager
import com.roundingmobile.sshworkbench.data.local.SavedConnection
import com.roundingmobile.sshworkbench.pro.ProFeatures
import com.roundingmobile.sshworkbench.terminal.KeyboardDisplaySettings
import com.roundingmobile.sshworkbench.terminal.KeyboardSettingsDialog
import com.roundingmobile.sshworkbench.terminal.KeyboardSettingsScreen
import com.roundingmobile.sshworkbench.ui.viewmodel.EditConnectionViewModel
private val TERMINAL_THEMES = listOf(
@ -682,24 +682,24 @@ fun EditConnectionScreen(
}
// --- Keyboard Settings ---
val kbContext = LocalContext.current
var showKbSettings by remember { mutableStateOf(false) }
OutlinedButton(
onClick = {
val current = KeyboardDisplaySettings.fromJson(viewModel.keyboardSettings)
?: KeyboardDisplaySettings()
KeyboardSettingsDialog.show(
context = kbContext,
currentSettings = current,
proFeatures = proFeatures,
onSave = { newSettings ->
viewModel.keyboardSettings = newSettings.toJson()
}
)
},
onClick = { showKbSettings = true },
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.keyboard_layout_colors))
}
if (showKbSettings) {
KeyboardSettingsScreen(
currentSettings = KeyboardDisplaySettings.fromJson(viewModel.keyboardSettings)
?: KeyboardDisplaySettings(),
proFeatures = proFeatures,
onSave = { newSettings ->
viewModel.keyboardSettings = newSettings.toJson()
},
onDismiss = { showKbSettings = false }
)
}
Spacer(modifier = Modifier.height(16.dp))

View file

@ -50,9 +50,11 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.material3.OutlinedButton
import com.roundingmobile.sshworkbench.R
import com.roundingmobile.sshworkbench.terminal.AqbSettingsScreen
import com.roundingmobile.sshworkbench.terminal.KeyboardDisplaySettings
import com.roundingmobile.sshworkbench.terminal.KeyboardSettingsDialog
import com.roundingmobile.sshworkbench.terminal.ThemePickerDialog
import com.roundingmobile.sshworkbench.terminal.KeyboardSettingsScreen
import com.roundingmobile.sshworkbench.terminal.QuickBarDisplaySettings
import com.roundingmobile.sshworkbench.terminal.ThemePickerSheet
import com.roundingmobile.sshworkbench.ui.viewmodel.SettingsViewModel
import androidx.fragment.app.FragmentActivity
import com.roundingmobile.sshworkbench.auth.BiometricAuthManager
@ -115,7 +117,7 @@ fun SettingsScreen(
}
item {
val themeContext = LocalContext.current
var showThemePicker by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
@ -135,16 +137,17 @@ fun SettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
OutlinedButton(onClick = {
ThemePickerDialog.show(
context = themeContext,
currentThemeName = themeName,
onSave = { name -> viewModel.setThemeName(name) }
)
}) {
OutlinedButton(onClick = { showThemePicker = true }) {
Text(stringResource(R.string.edit))
}
}
if (showThemePicker) {
ThemePickerSheet(
currentThemeName = themeName,
onSave = { name -> viewModel.setThemeName(name) },
onDismiss = { showThemePicker = false }
)
}
}
item {
@ -363,38 +366,69 @@ fun SettingsScreen(
}
}
item {
val kbSettingsContext = LocalContext.current
OutlinedButton(
onClick = {
val current = KeyboardDisplaySettings(
language = viewModel.getKeyboardLanguage(),
heightPercent = viewModel.getKeyboardHeightPercent(),
heightLandscape = viewModel.getKeyboardHeightLandscape(),
sameSizeBoth = viewModel.getKeyboardSameSizeBoth(),
showPageIndicators = viewModel.getShowPageIndicators(),
keyColorPreset = viewModel.getKeyColorPreset(),
keyColorCustom = viewModel.getKeyColorCustom(),
showHints = viewModel.getShowKeyHints(),
keyRepeatDelay = viewModel.getKeyRepeatDelay(),
longPressDelay = viewModel.getLongPressDelay(),
quickBarPosition = viewModel.getQuickBarPosition(),
quickBarSize = viewModel.getQuickBarSize(),
qbColorPreset = viewModel.getQbColorPreset(),
qbColorCustom = viewModel.getQbColorCustom()
)
KeyboardSettingsDialog.show(
context = kbSettingsContext,
currentSettings = current,
// CKB mode: show full keyboard + CQB settings dialog
if (keyboardType == "custom") {
item {
var showKbSettings by remember { mutableStateOf(false) }
OutlinedButton(
onClick = { showKbSettings = true },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Text(stringResource(R.string.keyboard_layout_colors))
}
if (showKbSettings) {
KeyboardSettingsScreen(
currentSettings = KeyboardDisplaySettings(
language = viewModel.getKeyboardLanguage(),
heightPercent = viewModel.getKeyboardHeightPercent(),
heightLandscape = viewModel.getKeyboardHeightLandscape(),
sameSizeBoth = viewModel.getKeyboardSameSizeBoth(),
showPageIndicators = viewModel.getShowPageIndicators(),
keyColorPreset = viewModel.getKeyColorPreset(),
keyColorCustom = viewModel.getKeyColorCustom(),
showHints = viewModel.getShowKeyHints(),
keyRepeatDelay = viewModel.getKeyRepeatDelay(),
longPressDelay = viewModel.getLongPressDelay(),
quickBarPosition = viewModel.getQuickBarPosition(),
quickBarSize = viewModel.getQuickBarSize(),
qbColorPreset = viewModel.getQbColorPreset(),
qbColorCustom = viewModel.getQbColorCustom()
),
proFeatures = proFeatures,
onSave = { newSettings -> viewModel.saveKeyboardDefaults(newSettings) }
onSave = { newSettings -> viewModel.saveKeyboardDefaults(newSettings) },
onDismiss = { showKbSettings = false }
)
},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Text(stringResource(R.string.keyboard_layout_colors))
}
}
}
// AKB mode: show AQB settings dialog (position, size, color only)
if (keyboardType == "system") {
item {
var showAqbSettings by remember { mutableStateOf(false) }
OutlinedButton(
onClick = { showAqbSettings = true },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Text(stringResource(R.string.quick_bar_settings))
}
if (showAqbSettings) {
AqbSettingsScreen(
currentSettings = QuickBarDisplaySettings(
position = viewModel.getAqbPosition(),
size = viewModel.getAqbSize(),
colorPreset = viewModel.getAqbColorPreset(),
colorCustom = viewModel.getAqbColorCustom()
),
proFeatures = proFeatures,
onSave = { s -> viewModel.saveAqbSettings(s.position, s.size, s.colorPreset, s.colorCustom) },
onDismiss = { showAqbSettings = false }
)
}
}
}

View file

@ -141,6 +141,21 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { terminalPreferences.setAppLanguage(language) }
}
// AQB — Quick Bar settings for system keyboard mode
fun getAqbPosition(): String = runBlocking { terminalPreferences.aqbPosition.first() }
fun getAqbSize(): Int = runBlocking { terminalPreferences.aqbSize.first() }
fun getAqbColorPreset(): String = runBlocking { terminalPreferences.aqbColorPreset.first() }
fun getAqbColorCustom(): String = runBlocking { terminalPreferences.aqbColorCustom.first() }
fun saveAqbSettings(position: String, size: Int, colorPreset: String, colorCustom: String) {
viewModelScope.launch {
terminalPreferences.setAqbPosition(position)
terminalPreferences.setAqbSize(size)
terminalPreferences.setAqbColorPreset(colorPreset)
terminalPreferences.setAqbColorCustom(colorCustom)
}
}
// Keyboard layout defaults — read synchronously for dialog pre-fill
fun getKeyboardLanguage(): String = runBlocking { terminalPreferences.keyboardLanguage.first() }
fun getKeyboardHeightPercent(): Float = runBlocking { terminalPreferences.keyboardHeightPercent.first() }

View file

@ -233,6 +233,7 @@
<string name="keyboard_tab">Teclado</string>
<string name="quickbar_tab">Barra rápida</string>
<string name="keyboard_layout_colors">Ajustes de teclado</string>
<string name="quick_bar_settings">Ajustes de barra rápida</string>
<string name="no_device_lock_warning">Para usar el bloqueo biométrico, primero debes configurar un bloqueo de pantalla (PIN, patrón o contraseña) en los ajustes del dispositivo.</string>
<string name="auto_reconnect_label">Reconexión automática</string>
<string name="auto_reconnect_desc">Reconectar automáticamente cuando se pierde la conexión</string>

View file

@ -232,6 +232,7 @@
<string name="keyboard_tab">Tangentbord</string>
<string name="quickbar_tab">Snabbfält</string>
<string name="keyboard_layout_colors">Tangentbordsinställningar</string>
<string name="quick_bar_settings">Snabbfältsinställningar</string>
<string name="no_device_lock_warning">För att använda biometriskt lås måste du först ställa in ett skärmlås (PIN, mönster eller lösenord) i enhetens inställningar.</string>
<string name="auto_reconnect_label">Automatisk återanslutning</string>
<string name="auto_reconnect_desc">Återanslut automatiskt vid förlorad anslutning</string>

View file

@ -236,6 +236,7 @@
<!-- Edit Connection Screen -->
<string name="keyboard_layout_colors">Keyboard Settings</string>
<string name="quick_bar_settings">Quick Bar Settings</string>
<string name="no_device_lock_warning">To use biometric lock, you need to set up a screen lock (PIN, pattern, or password) in your device settings first.</string>
<!-- Edit Connection -->

View file

@ -31,6 +31,10 @@
- **Clipboard timed clear** — auto-clear clipboard N seconds after copy for security. Currently clipboard contents persist indefinitely.
- **Vault crypto unit tests**`lib-vault-crypto` JNI (Argon2id + AES-256-GCM) has no unit tests. Requires a test harness that loads the native library.
## Research
- **Termius Android changelog** — check https://termius.com/changelog for feature ideas and competitive intelligence on what users expect from a mobile SSH client.
## Vault Export — Future Options
### Option C — Recipient public key (X25519)

9
docs/GLOSSARY.md Normal file
View file

@ -0,0 +1,9 @@
# Glossary
| Abbrev | Name | Description |
|--------|------|-------------|
| **TV** | Terminal View | The terminal surface where sessions are displayed, typed into, and interacted with |
| **AKB** | Android Keyboard | System IME (Google Keyboard, Samsung Keyboard, etc.) |
| **CKB** | Custom Keyboard | Our Canvas-based keyboard (`lib-terminal-keyboard`) |
| **AQB** | AKB Quick Bar | Quick Bar shown when using the Android Keyboard |
| **CQB** | CKB Quick Bar | Quick Bar shown when using the Custom Keyboard |

141
docs/KEYBOARD.md Normal file
View file

@ -0,0 +1,141 @@
# Keyboard System
> Last updated: 2026-04-03
> See also: `docs/GLOSSARY.md` for abbreviations
---
## Glossary
| Abbrev | Name | Description |
|--------|------|-------------|
| **TV** | Terminal View | Terminal surface — displays sessions, receives input |
| **AKB** | Android Keyboard | System IME (Gboard, Samsung, etc.) |
| **CKB** | Custom Keyboard | Our Canvas-based keyboard (`lib-terminal-keyboard`) |
| **AQB** | AKB Quick Bar | Quick Bar when using the Android Keyboard |
| **CQB** | CKB Quick Bar | Quick Bar when using the Custom Keyboard |
---
## Architecture
Two keyboard modes, selected in Settings → Keyboard → Keyboard Type:
```
┌─────────────────────────────────────────┐
│ TV (TerminalSurfaceView) │
├─────────────────────────────────────────┤
│ Quick Bar (AQB or CQB) │ ← always visible (if enabled)
├─────────────────────────────────────────┤
│ CKB (custom keyboard pages) │ ← only in CKB mode
│ — or — │
│ AKB (system IME, managed by Android) │ ← only in AKB mode
└─────────────────────────────────────────┘
```
### CKB Mode
- Our custom Canvas keyboard (`lib-terminal-keyboard`) renders below the QB
- System IME is explicitly hidden (`softInputEnabled = false`)
- CQB keys: ESC, TAB, `:`, `~`, `|`, arrows, Shift+Tab, vim, nano, tmux, screen
- CKB settings: language, height, page indicators, key colors, hints, repeat delay, long press delay
- CQB settings: position, size, color
### AKB Mode
- System IME manages itself (Android handles show/hide)
- CKB view is hidden (`container.visibility = GONE`)
- AQB sits above the system keyboard or at screen top
- AQB settings: position, size, color (independent from CQB)
---
## Preferences
### Shared (both modes)
| Pref | Key | Default |
|------|-----|---------|
| Keyboard type | `keyboard_type` | `"custom"` |
| Haptic feedback | `haptic_feedback` | `true` |
| Show Quick Bar | `quick_bar_visible` | `true` |
### CKB-only
| Pref | Key | Default |
|------|-----|---------|
| Language | `keyboard_language` | `"en"` |
| Height (portrait) | `keyboard_height_percent` | `0.27` |
| Height (landscape) | `keyboard_height_landscape` | `0.27` |
| Same size both | `keyboard_same_size_both` | `true` |
| Page indicators | `show_page_indicators` | `true` |
| Key color preset | `key_color_preset` | `"default"` |
| Key color custom | `key_color_custom` | `""` |
| Show hints | `show_key_hints` | `true` |
| Key repeat delay | `key_repeat_delay` | `400` |
| Long press delay | `long_press_delay` | `350` |
### CQB (Custom Keyboard Quick Bar)
| Pref | Key | Default |
|------|-----|---------|
| Position | `quick_bar_position` | `"above_keyboard"` |
| Size | `quick_bar_size` | `42` |
| Color preset | `qb_color_preset` | `"default"` |
| Color custom | `qb_color_custom` | `""` |
### AQB (Android Keyboard Quick Bar)
Positions: `top`, `vertical_left`, `vertical_right`, `none` (no `above_keyboard`/`below_keyboard` — no CKB to be relative to)
| Pref | Key | Default |
|------|-----|---------|
| Position | `aqb_position` | `"top"` |
| Size | `aqb_size` | `42` |
| Color preset | `aqb_color_preset` | `"default"` |
| Color custom | `aqb_color_custom` | `""` |
---
## Settings UI
Settings → Keyboard section adapts based on `keyboard_type`:
**CKB selected**: Haptic, Show QB, Type selector, "Keyboard Settings" button → opens `KeyboardSettingsDialog` (two tabs: Keyboard + Quick Bar)
**AKB selected**: Haptic, Show QB, Type selector, "Quick Bar Settings" button → opens `KeyboardSettingsDialog.showAqb()` (position, size, color only)
---
## Key Files
| File | Purpose |
|------|---------|
| `lib-terminal-keyboard/` | Standalone keyboard library (Canvas, JSON layouts, language packs) |
| `TerminalKeyboard.kt` | Entry point, Builder, touch handling, popup management |
| `KeyboardView.kt` | ViewPager2 for swipeable keyboard pages |
| `KeyboardPageView.kt` | Canvas rendering + hit testing per page |
| `QuickBarView.kt` | Canvas QB with infinite scroll, menu keys, modifier highlights |
| `ModifierStateManager.kt` | CTRL/ALT/SHIFT state machine (IDLE → ARMED → LOCKED) |
| `KeyboardSettingsDialog.kt` | CKB+CQB settings dialog + AQB settings dialog |
| `KeyboardPreviewView.kt` | Phone-shaped preview widget in settings dialog |
| `TerminalPreferences.kt` | DataStore prefs for all keyboard settings |
| `SettingsScreen.kt` | Compose settings UI with mode-aware keyboard section |
| `TerminalPane.kt` | Wires keyboard to terminal (CKB visibility, QB, key events) |
| `MainActivity.kt` | Keyboard instance lifecycle, QB/CKB attachment, size routing |
---
## Quick Bar Keys
### CQB (Custom Keyboard mode)
Defined in `layout_qwerty.json``quickBar.keys`:
ESC, TAB, `:`, `~`, `|`, ←, →, Shift+Tab, vim, nano, tmux, screen
### AQB (System Keyboard mode)
Currently uses the same key set as CQB.
**Planned** (from TECHNICAL.md): separate key set with CTRL, ESC, TAB, `:`, `/`, arrows, HOME, END, PGUP, PGDN, F1-F12. Not yet implemented.
---
## Temporary Hide (CKB only)
- Gear long-press → "Hide Keyboard" sets `ckbHidden` Compose state (not a pref change)
- CKB view hidden, QB stays visible
- Tapping TV fires `onTapShowKeyboard` → resets `ckbHidden`
- Does not affect AKB mode (system IME has its own show/hide)

204
docs/TESTING_ADB.md Normal file
View file

@ -0,0 +1,204 @@
# ADB Automated Test Framework
> Last updated: 2026-04-03
---
## Overview
Python-based test framework that drives the app via ADB on a Zebra TC21, captures screenshots, checks logs, and uses `claude -p` for AI-powered visual verification of each screenshot.
## Structure
```
scripts/
├── test.py ← runner: menu, --all, or by number
├── test_lib/
│ └── common.py ← TestRunner class with all helpers
├── tests/
│ ├── 01_connect_sshtest.py ← SSH connect, echo, whoami, ls --color
│ ├── 02_htop.py ← htop TUI: sort, tree, search, help
│ ├── 03_vim_edit.py ← vim: create, edit, search, undo, save, :q!
│ ├── 04_password_prompt.py ← no-password connect, type pw, save pw, auto-connect
│ └── 05_multi_session.py ← multi-tab, SFTP, background/resume, close
└── test_results/
└── 2026-04-03_HHMMSS/ ← timestamped per run
├── <test>_01_<label>.png
├── <test>_verdicts.txt
├── <test>_log.txt
└── manifest.json ← structured results for cross-run comparison
```
## Usage
```bash
# Interactive menu
python3 scripts/test.py
# Run all tests
python3 scripts/test.py --all
# Run a single test by number or name prefix
python3 scripts/test.py 01
python3 scripts/test.py 03
```
## Prerequisites
- App installed on Zebra TC21 (pro debug APK)
- Zebra connected via ADB (serial `22160523026079`)
- `SSHTest` profile saved with stored password (sshtest@duero)
- `SSHTest NoPw` profile saved with blank password (for test 04)
- `claude` CLI available in PATH (for AI verification)
- `htop` installed on duero (for test 02)
## TestRunner API (`common.py`)
### App Control
| Method | Description |
|--------|-------------|
| `force_stop()` | Kill the app process |
| `launch(*extras)` | Launch MainActivity with extras |
| `launch_profile(name, clear_log=True)` | Launch with `--es profile "name"` |
### Terminal Input (via ADB broadcast)
| Method | Description |
|--------|-------------|
| `send_text(text)` | Type text into active terminal session |
| `send_enter()` | Send Enter (0x0D) |
| `send_esc(seq)` | Send ESC + sequence (e.g. `"[D"` for left arrow) |
| `send_bytes(hex)` | Send raw hex bytes (e.g. `"1b 62"` for Alt+Left) |
| `set_font_size(sp)` | Set terminal font size (6-34 sp) |
| `clear_password(name)` | Delete saved password for a connection by name |
### Screenshots & Logs
| Method | Description |
|--------|-------------|
| `screenshot(label)` | Capture screenshot, save to results dir, return path |
| `pull_log()` | Pull app debug log to results dir |
| `read_log()` | Read current app debug log content |
| `wait_for_log(pattern, timeout=15)` | Poll log for pattern (seconds) |
| `log_contains(pattern)` | Check if pattern exists in log |
### Verification
| Method | Description |
|--------|-------------|
| `verify(desc, img_path, question)` | AI visual check — pipes screenshot to `claude -p` which reads the image and answers PASS/FAIL |
| `assert_log(desc, pattern)` | Automated log pattern check — PASS/FAIL |
### Lifecycle
| Method | Description |
|--------|-------------|
| `begin(name)` | Start a named test |
| `summary()` | Print results, save verdicts + manifest.json, return True if all passed |
## AI Verification
Each `verify()` call spawns a separate `claude -p --no-session-persistence --allowedTools "Read"` process. The prompt tells Claude to read the screenshot PNG and answer PASS or FAIL with a short reason.
```python
t.verify("Terminal shows prompt", img,
"Is this a connected SSH terminal showing a shell prompt?")
```
Output:
```
[verify] Terminal shows prompt ... PASS
```
## ADB Broadcast API
The app registers a broadcast receiver (debug builds only) at action `com.roundingmobile.sshworkbench.INPUT`:
| Extra | Type | Description |
|-------|------|-------------|
| `text` | String | Send text to active terminal |
| `enter` | Boolean | Send Enter (0x0D) |
| `esc` | String | Send ESC + string |
| `bytes` | String | Send raw hex bytes |
| `fontsize` | String | Set font size (e.g. "8.0") |
| `clearPassword` | String | Delete saved password for connection name |
| `log` | Boolean | Log cursor position |
| `dump` | Boolean | Dump full screen buffer to log |
## UI Interaction via ADB
For Compose dialogs and UI elements that can't be reached via broadcast (password dialog, menus, buttons), use `adb shell input`:
```python
t.adb_shell("input text 'password'") # type into focused field
t.adb_shell("input tap 550 840") # tap at coordinates
t.adb_shell("input keyevent KEYCODE_BACK") # dismiss keyboard/menu
t.adb_shell("input keyevent KEYCODE_HOME") # background app
t.adb_shell("input keyevent KEYCODE_APP_SWITCH") # open recents
```
### Tab Bar Coordinates (720px wide, 2x density, 135dp tabs)
| Target | X | Y |
|--------|---|---|
| Tab 1 label | 67 | 88 |
| Tab 1 dots (⋮) | 125 | 88 |
| Tab 2 label | 202 | 88 |
| Tab 2 dots (⋮) | 260 | 88 |
| Tab 3 label | 337 | 88 |
| + button | 660 | 88 |
### Overflow Menu Items (from tab ⋮)
| Item | Y |
|------|---|
| Duplicate | 145 |
| Connect via SFTP | 240 |
| Rename | 335 |
| Theme | 430 |
| Close | 525 |
## Test Descriptions
### 01_connect_sshtest (5 checks)
Connect to duero via SSHTest profile. Verify terminal prompt, echo command output, whoami, colored ls.
### 02_htop (8 checks)
Launch htop. Verify main screen, sort by memory (M), sort by CPU (P), tree view (t), search for sshd (/), help screen (h), clean quit (q).
### 03_vim_edit (11 checks)
Open vim, enter insert mode, type 7 lines, search (/FINDME), delete line (dd), undo (u), save (:wq), verify with cat + wc -l. Reopen, add line, force quit (:q!), verify unsaved changes discarded.
### 04_password_prompt (12 checks)
Clears any saved password first. Connect with blank-password profile → password dialog appears. Type password → Connect → terminal works. Exit → reconnect → prompted again (not saved). Type + check Remember → Connect. Exit → reconnect → auto-connects (saved). Cleanup: clears saved password at end.
### 05_multi_session (16 checks)
Open session 1, open session 2 via + button. Switch between tabs. Open SFTP via overflow menu. Switch back to terminal. Press Home → resume from recents. Verify both sessions survived. Close session 2 via overflow → Close. Verify remaining session works.
## Results & Comparison
Each run saves to `scripts/test_results/<timestamp>/` with:
- Screenshots (PNG) for every verification step
- `<test>_verdicts.txt` — PASS/FAIL verdicts with AI reasoning
- `<test>_log.txt` — full app debug log
- `manifest.json` — structured results for automated comparison
```json
{
"tests": [
{
"name": "connect_sshtest",
"passed": 5, "failed": 0, "skipped": 0, "total": 5,
"verdicts": ["Terminal shows shell prompt: PASS — ..."],
"timestamp": "2026-04-03T12:16:47"
}
],
"summary": { "total_passed": 52, "total_failed": 0, "total_skipped": 0 }
}
```
To compare runs: diff the `manifest.json` files or visually compare screenshot pairs from different timestamps.
## Tips
- **Stale log**: Always delete the log file before launching (`t.adb_shell(f"rm -f {t.LOG_PATH}")`) to avoid `wait_for_log` matching old entries.
- **Font size**: Set to 7sp early in each test for more visible content in screenshots.
- **Timing**: Use `wait_for_log("Connected visible=true")` instead of fixed sleeps — it waits for the terminal pane to be visible AND connected.
- **Keyboard dismiss**: After `input text`, the system keyboard pops up. Send `KEYCODE_BACK` before tapping buttons.
- **Test isolation**: Each test should `force_stop()` at the start for a clean state.

59
scripts/test.py Executable file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""SSH Workbench Test Runner — menu or CLI."""
import sys, glob, os, importlib.util
from pathlib import Path
TESTS_DIR = Path(__file__).resolve().parent / "tests"
tests = sorted(TESTS_DIR.glob("*.py"))
if not tests:
print("No tests found")
sys.exit(1)
def run_test(path):
print(f"\n>>> Running: {path.name}")
spec = importlib.util.spec_from_file_location(path.stem, path)
mod = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(mod)
except SystemExit as e:
return e.code == 0
return True
# CLI: --all
if len(sys.argv) > 1 and sys.argv[1] == "--all":
ok = all(run_test(t) for t in tests)
sys.exit(0 if ok else 1)
# CLI: number or prefix
if len(sys.argv) > 1:
match = [t for t in tests if sys.argv[1] in t.name]
if match:
ok = run_test(match[0])
sys.exit(0 if ok else 1)
print(f"No test matching '{sys.argv[1]}'")
sys.exit(1)
# Interactive menu
print("\n=== SSH Workbench Test Runner ===\n")
for i, t in enumerate(tests):
print(f" {i+1}. {t.stem}")
print(f"\n a. Run all")
print(f" q. Quit\n")
choice = input("Choice: ").strip()
if choice in ("q", "Q"):
sys.exit(0)
if choice in ("a", "A"):
ok = all(run_test(t) for t in tests)
sys.exit(0 if ok else 1)
try:
idx = int(choice) - 1
if 0 <= idx < len(tests):
ok = run_test(tests[idx])
sys.exit(0 if ok else 1)
except ValueError:
pass
print("Invalid choice")
sys.exit(1)

87
scripts/test.sh Executable file
View file

@ -0,0 +1,87 @@
#!/bin/bash
# SSH Workbench Test Runner
#
# Usage:
# ./scripts/test.sh ← interactive menu
# ./scripts/test.sh --all ← run all tests
# ./scripts/test.sh 01 ← run test 01 only
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TESTS_DIR="$SCRIPT_DIR/tests"
source "$SCRIPT_DIR/test_lib/common.sh"
init_results
# Collect available tests
mapfile -t TEST_FILES < <(ls "$TESTS_DIR"/*.sh 2>/dev/null | sort)
if [ ${#TEST_FILES[@]} -eq 0 ]; then
echo "No tests found in $TESTS_DIR"
exit 1
fi
run_test() {
local test_file="$1"
echo ""
echo ">>> Running: $(basename "$test_file")"
export RESULTS_DIR
source "$test_file"
}
# --- CLI mode ---
if [ "${1:-}" = "--all" ]; then
echo "Running all ${#TEST_FILES[@]} tests..."
for f in "${TEST_FILES[@]}"; do
run_test "$f"
done
print_summary
exit $?
fi
# --- Single test by number ---
if [ -n "${1:-}" ]; then
match=$(printf '%s\n' "${TEST_FILES[@]}" | grep "/${1}" | head -1)
if [ -n "$match" ]; then
run_test "$match"
print_summary
exit $?
else
echo "No test matching '$1'"
exit 1
fi
fi
# --- Interactive menu ---
echo ""
echo "=== SSH Workbench Test Runner ==="
echo ""
for i in "${!TEST_FILES[@]}"; do
echo " $((i+1)). $(basename "${TEST_FILES[$i]}" .sh)"
done
echo ""
echo " a. Run all"
echo " q. Quit"
echo ""
read -rp "Choice: " choice
case "$choice" in
a|A)
for f in "${TEST_FILES[@]}"; do
run_test "$f"
done
;;
q|Q)
exit 0
;;
*)
idx=$((choice - 1))
if [ "$idx" -ge 0 ] && [ "$idx" -lt ${#TEST_FILES[@]} ]; then
run_test "${TEST_FILES[$idx]}"
else
echo "Invalid choice"
exit 1
fi
;;
esac
print_summary

Binary file not shown.

211
scripts/test_lib/common.py Normal file
View file

@ -0,0 +1,211 @@
"""Common test helpers for SSH Workbench ADB testing."""
import os
import subprocess
import time
from datetime import datetime
from pathlib import Path
# --- Config ---
ZEBRA_SERIAL = "22160523026079"
PKG_PRO = "com.roundingmobile.sshworkbench.pro"
ACTIVITY = "com.roundingmobile.sshworkbench.ui.MainActivity"
ACTION = "com.roundingmobile.sshworkbench.INPUT"
LOG_PATH = "/sdcard/Download/SshWorkbench/sshworkbench_debug.txt"
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
RESULTS_BASE = SCRIPTS_DIR / "test_results"
class TestRunner:
LOG_PATH = LOG_PATH
def __init__(self):
self.results_dir = RESULTS_BASE / datetime.now().strftime("%Y-%m-%d_%H%M%S")
self.results_dir.mkdir(parents=True, exist_ok=True)
self.test_name = ""
self.step = 0
self.passes = 0
self.fails = 0
self.skips = 0
self.verdicts: list[str] = []
print(f"Results: {self.results_dir}")
# --- ADB ---
def adb(self, *args, capture=False, timeout=10):
cmd = ["adb", "-s", ZEBRA_SERIAL] + list(args)
if capture:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.stdout
else:
subprocess.run(cmd, capture_output=True, timeout=timeout)
def adb_shell(self, command, capture=False, timeout=10):
if capture:
return self.adb("shell", command, capture=True, timeout=timeout)
self.adb("shell", command, timeout=timeout)
# --- Test lifecycle ---
def begin(self, name):
self.test_name = name
self.step = 0
print(f"\n{'='*42}")
print(f" TEST: {name}")
print(f"{'='*42}")
# --- App control ---
def force_stop(self):
self.adb_shell(f"am force-stop {PKG_PRO}")
time.sleep(1)
def launch(self, *extras):
extra_str = " ".join(extras)
self.adb_shell(f"am start -n {PKG_PRO}/{ACTIVITY} {extra_str}")
def launch_profile(self, profile, clear_log=True):
extras = f'--es profile "{profile}"'
if clear_log:
extras += " --ez clearLog true"
self.launch(extras)
# --- Input ---
def send_text(self, text):
self.adb_shell(f"am broadcast -a {ACTION} --es text '{text}'")
time.sleep(0.3)
def send_enter(self):
self.adb_shell(f"am broadcast -a {ACTION} --ez enter true")
time.sleep(0.5)
def send_esc(self, seq):
self.adb_shell(f"am broadcast -a {ACTION} --es esc '{seq}'")
time.sleep(0.3)
def send_bytes(self, hex_str):
self.adb_shell(f"am broadcast -a {ACTION} --es bytes '{hex_str}'")
time.sleep(0.3)
def set_font_size(self, size_sp):
"""Set terminal font size (6.0 - 34.0 sp)."""
self.adb_shell(f"am broadcast -a {ACTION} --es fontsize '{size_sp}'")
time.sleep(0.5)
def clear_password(self, connection_name):
"""Clear saved password for a connection by name (requires app running)."""
self.adb_shell(f'''am broadcast -a {ACTION} --es clearPassword "{connection_name}"''')
time.sleep(1)
# --- Log ---
def read_log(self):
return self.adb_shell(f"cat {LOG_PATH}", capture=True, timeout=5) or ""
def wait_for_log(self, pattern, timeout=15):
"""Poll log every 0.5s until pattern found or timeout (seconds)."""
deadline = time.time() + timeout
while time.time() < deadline:
log = self.read_log()
if pattern in log:
return True
time.sleep(0.5)
print(f" [TIMEOUT] waiting for: {pattern}")
return False
def log_contains(self, pattern):
return pattern in self.read_log()
def pull_log(self):
fname = f"{self.test_name}_log.txt"
path = self.results_dir / fname
log = self.read_log()
path.write_text(log)
print(f" [log] {fname}")
return path
# --- Screenshot ---
def screenshot(self, label="step"):
self.step += 1
fname = f"{self.test_name}_{self.step:02d}_{label}.png"
path = self.results_dir / fname
raw = subprocess.run(
["adb", "-s", ZEBRA_SERIAL, "exec-out", "screencap", "-p"],
capture_output=True, timeout=10
)
path.write_bytes(raw.stdout)
print(f" [screenshot] {fname}")
return path
# --- Verification ---
def verify(self, description, img_path, question):
"""AI-verified screenshot via claude -p."""
print(f" [verify] {description} ... ", end="", flush=True)
prompt = (
f"Read the image at {img_path} and answer this question about "
f"an Android SSH terminal app screenshot:\n\n{question}\n\n"
f"Answer with exactly one line: PASS or FAIL followed by a short reason (under 20 words)."
)
try:
r = subprocess.run(
["claude", "-p", "--no-session-persistence", "--allowedTools", "Read"],
input=prompt, capture_output=True, text=True, timeout=60
)
result = r.stdout.strip().split("\n")[-1] if r.stdout.strip() else "NO OUTPUT"
except subprocess.TimeoutExpired:
result = "TIMEOUT"
except Exception as e:
result = f"ERROR: {e}"
if result.upper().startswith("PASS"):
print("PASS")
self.passes += 1
elif result.upper().startswith("FAIL"):
print(f"FAIL — {result}")
self.fails += 1
else:
print(f"SKIP — {result}")
self.skips += 1
self.verdicts.append(f"{description}: {result}")
def assert_log(self, description, pattern):
"""Check log contains pattern — automated pass/fail."""
print(f" [assert] {description} ... ", end="", flush=True)
if self.log_contains(pattern):
print("PASS")
self.passes += 1
else:
print(f"FAIL — pattern not found: {pattern}")
self.fails += 1
# --- Summary ---
def summary(self):
# Save verdicts
verdicts_file = self.results_dir / f"{self.test_name}_verdicts.txt"
verdicts_file.write_text("\n".join(self.verdicts) + "\n")
# Save session manifest (for comparison across runs)
import json
manifest_file = self.results_dir / "manifest.json"
existing = json.loads(manifest_file.read_text()) if manifest_file.exists() else {"tests": []}
existing["tests"].append({
"name": self.test_name,
"passed": self.passes,
"failed": self.fails,
"skipped": self.skips,
"total": self.passes + self.fails + self.skips,
"verdicts": self.verdicts,
"timestamp": datetime.now().isoformat()
})
existing["summary"] = {
"total_passed": sum(t["passed"] for t in existing["tests"]),
"total_failed": sum(t["failed"] for t in existing["tests"]),
"total_skipped": sum(t["skipped"] for t in existing["tests"]),
}
manifest_file.write_text(json.dumps(existing, indent=2) + "\n")
print(f"\n{'='*42}")
print(f" RESULTS: {self.passes} passed, {self.fails} failed, {self.skips} skipped")
print(f" Output: {self.results_dir}")
print(f"{'='*42}")
return self.fails == 0

View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Test: Connect to duero via SSHTest profile, verify terminal is live."""
import sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "test_lib"))
from common import TestRunner
t = TestRunner()
t.begin("connect_sshtest")
# 1. Clean start — stop app and delete old log
print(" Stopping app...")
t.force_stop()
t.adb_shell(f"rm -f {t.LOG_PATH}")
time.sleep(0.5)
# 2. Launch with SSHTest profile
print(" Launching with SSHTest profile...")
t.launch_profile("SSHTest", clear_log=False)
# 3. Wait for terminal pane visible + connected
print(" Waiting for terminal pane connected...")
t.wait_for_log("Connected visible=true", timeout=20)
t.set_font_size(7)
time.sleep(1) # Let shell prompt render
# 4. Verify connected terminal
img = t.screenshot("connected")
t.verify("Terminal shows shell prompt", img,
"Is this a connected SSH terminal showing a shell prompt (like sshtest@duero:~$)? "
"It should NOT be a splash screen, connection list, or error.")
t.assert_log("Session state is Connected", "state: Connected")
# 5. echo command
t.send_text("echo TESTPING_OK")
t.send_enter()
time.sleep(2)
img = t.screenshot("after_echo")
t.verify("echo output visible", img,
"Does the terminal show TESTPING_OK as output from an echo command?")
# 6. whoami
t.send_text("whoami")
t.send_enter()
time.sleep(2)
img = t.screenshot("after_whoami")
t.verify("whoami shows sshtest", img,
"Does the terminal show 'sshtest' as output of the whoami command?")
# 7. Colored ls
t.send_text("ls --color /etc | head -20")
t.send_enter()
time.sleep(2)
img = t.screenshot("after_ls_color")
t.verify("Colored directory listing", img,
"Does the terminal show a directory listing with colored filenames?")
# 8. Log
t.pull_log()
ok = t.summary()
sys.exit(0 if ok else 1)

99
scripts/tests/02_htop.py Normal file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Test: htop — full-screen TUI rendering, color, scrolling, interactive options."""
import sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "test_lib"))
from common import TestRunner
t = TestRunner()
t.begin("htop")
# Clean start
t.force_stop()
t.adb_shell(f"rm -f {t.LOG_PATH}")
time.sleep(0.5)
t.launch_profile("SSHTest", clear_log=False)
print(" Waiting for connection...")
t.wait_for_log("Connected visible=true", timeout=20)
t.set_font_size(7)
time.sleep(1)
# 1. Launch htop
t.send_text("htop")
t.send_enter()
time.sleep(3)
img = t.screenshot("htop_initial")
t.verify("htop main screen", img,
"Does this show htop running with CPU/memory bars at top, process list below, "
"and function key bar (F1-F10) at the bottom? Colored output expected.")
# 2. Sort by memory — press M
t.send_text("M")
time.sleep(2)
img = t.screenshot("htop_sort_mem")
t.verify("htop sorted by memory", img,
"Does htop show processes sorted by memory usage (MEM% column highlighted or "
"highest memory processes at top)?")
# 3. Sort by CPU — press P
t.send_text("P")
time.sleep(2)
img = t.screenshot("htop_sort_cpu")
t.verify("htop sorted by CPU", img,
"Does htop show processes sorted by CPU usage?")
# 4. Tree view — quit and relaunch htop for clean state, then press t
t.send_text("q")
time.sleep(1)
t.send_text("htop")
t.send_enter()
time.sleep(3)
t.send_text("t")
time.sleep(2)
img = t.screenshot("htop_tree")
t.verify("htop tree view", img,
"Does htop show a process tree? Look for indented child processes and "
"the bottom bar showing 'F5Tree' instead of 'F5Sorted'.")
# 5. Search — press /
t.send_text("/")
time.sleep(0.5)
t.send_text("sshd")
time.sleep(1)
img = t.screenshot("htop_search")
t.verify("htop search for sshd", img,
"Does htop show a search bar with 'sshd' typed, and highlight a matching process?")
# 6. Cancel search with ESC
t.send_esc("")
time.sleep(0.5)
# 7. Help screen — press F1 (or h)
t.send_text("h")
time.sleep(1)
img = t.screenshot("htop_help")
t.verify("htop help screen", img,
"Does this show the htop help/about screen with keyboard shortcuts listed?")
# 8. Exit help with ESC, then quit htop with q
t.send_esc("")
time.sleep(0.5)
t.send_text("q")
time.sleep(1)
img = t.screenshot("htop_quit")
t.verify("Back to shell after htop", img,
"Is this a normal shell prompt after htop exited? No htop UI should be visible.")
t.assert_log("Session still connected", "state: Connected")
t.pull_log()
ok = t.summary()
sys.exit(0 if ok else 1)

View file

@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Test: vim — create file, edit, search, save, verify content."""
import sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "test_lib"))
from common import TestRunner
t = TestRunner()
t.begin("vim_edit")
# Clean start
t.force_stop()
t.adb_shell(f"rm -f {t.LOG_PATH}")
time.sleep(0.5)
t.launch_profile("SSHTest", clear_log=False)
print(" Waiting for connection...")
t.wait_for_log("Connected visible=true", timeout=20)
t.set_font_size(7)
time.sleep(1)
# Clean up any previous test file
t.send_text("rm -f /tmp/ssh_workbench_test.txt")
t.send_enter()
time.sleep(0.5)
# 1. Open vim with a new file
t.send_text("vim /tmp/ssh_workbench_test.txt")
t.send_enter()
time.sleep(2)
img = t.screenshot("vim_empty")
t.verify("vim opened empty file", img,
"Does this show vim editor with an empty buffer? Look for tildes (~) on the left "
"side and a status bar at the bottom showing the filename.")
# 2. Enter insert mode and type some text
t.send_text("i") # insert mode
time.sleep(0.3)
t.send_text("Line 1: SSH Workbench Test File")
t.send_enter()
t.send_text("Line 2: Testing terminal emulation")
t.send_enter()
t.send_text("Line 3: Special chars: ~!@#$%^&*()")
t.send_enter()
t.send_text("Line 4: Unicode test: cafe")
t.send_enter()
t.send_text("Line 5: The quick brown fox jumps over the lazy dog")
t.send_enter()
t.send_text("Line 6: FINDME_MARKER for search test")
t.send_enter()
t.send_text("Line 7: End of test file")
time.sleep(0.5)
img = t.screenshot("vim_text_entered")
t.verify("vim with text entered", img,
"Does vim show multiple lines of text including 'SSH Workbench Test File', "
"'Special chars', and 'FINDME_MARKER'? Should be in insert mode (-- INSERT -- at bottom).")
# 3. Exit insert mode
t.send_esc("")
time.sleep(0.5)
# 4. Search for FINDME
t.send_text("/FINDME")
t.send_enter()
time.sleep(0.5)
img = t.screenshot("vim_search")
t.verify("vim search found FINDME", img,
"Does vim show the cursor on or near the line containing 'FINDME_MARKER'? "
"The search term should be highlighted.")
# 5. Go to first line (gg)
t.send_text("gg")
time.sleep(0.3)
# 6. Delete first line (dd)
t.send_text("dd")
time.sleep(0.3)
img = t.screenshot("vim_after_delete")
t.verify("vim line deleted", img,
"Does vim now show 'Testing terminal emulation' as the first visible line? "
"The original 'Line 1: SSH Workbench Test File' should be gone.")
# 7. Undo (u)
t.send_text("u")
time.sleep(0.3)
img = t.screenshot("vim_after_undo")
t.verify("vim undo restored line", img,
"Does vim show 'Line 1: SSH Workbench Test File' back as the first line after undo?")
# 8. Save and quit (:wq)
t.send_text(":wq")
t.send_enter()
time.sleep(1)
img = t.screenshot("vim_saved")
t.verify("Back to shell after vim", img,
"Is this a normal shell prompt? vim should have exited after saving.")
# 9. Verify file content with cat
t.send_text("cat /tmp/ssh_workbench_test.txt")
t.send_enter()
time.sleep(1)
img = t.screenshot("cat_output")
t.verify("File content correct", img,
"Does the terminal show the contents of the file with all 7 lines including "
"'SSH Workbench Test File', 'FINDME_MARKER', and 'End of test file'?")
# 10. Verify line count
t.send_text("wc -l /tmp/ssh_workbench_test.txt")
t.send_enter()
time.sleep(1)
img = t.screenshot("wc_output")
t.verify("File has 7 lines", img,
"Does the wc -l output show 7 lines for the test file?")
# 11. Reopen in vim, make an edit, force quit without saving
t.send_text("vim /tmp/ssh_workbench_test.txt")
t.send_enter()
time.sleep(2)
img = t.screenshot("vim_reopen")
t.verify("vim reopened file", img,
"Does vim show the test file with all 7 lines?")
# Add a line at the end
t.send_text("G") # go to last line
time.sleep(0.2)
t.send_text("o") # open line below in insert mode
time.sleep(0.2)
t.send_text("Line 8: This should NOT be saved")
t.send_esc("")
time.sleep(0.3)
# Force quit without saving
t.send_text(":q!")
t.send_enter()
time.sleep(1)
# Verify line 8 was NOT saved
t.send_text("grep 'NOT be saved' /tmp/ssh_workbench_test.txt; echo EXIT_CODE=$?")
t.send_enter()
time.sleep(1)
img = t.screenshot("nosave_verify")
t.verify("Unsaved changes discarded", img,
"Does the output show EXIT_CODE=1 (grep found nothing)? "
"The 'NOT be saved' line should not appear in the file.")
# Cleanup
t.send_text("rm -f /tmp/ssh_workbench_test.txt")
t.send_enter()
time.sleep(0.5)
t.assert_log("Session still connected", "state: Connected")
t.pull_log()
ok = t.summary()
sys.exit(0 if ok else 1)

View file

@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Test: Password prompt — connect without stored password, type pw, save pw, revert."""
import sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "test_lib"))
from common import TestRunner
SSHTEST_PW = "svalbard###999333"
t = TestRunner()
t.begin("password_prompt")
# 1. Clean start — launch app first to clear any saved password, then restart
print(" Clearing any saved password from previous run...")
t.force_stop()
time.sleep(0.5)
t.launch('--es profile "SSHTest"') # launch with any profile to get app running
t.wait_for_log("Connected visible=true", timeout=20)
time.sleep(1)
t.clear_password("SSHTest NoPw")
time.sleep(1)
t.force_stop()
t.adb_shell(f"rm -f {t.LOG_PATH}")
time.sleep(0.5)
# 2. Launch with SSHTest NoPw profile (no stored password)
print(" Launching with 'SSHTest NoPw' profile...")
t.launch('--es profile "SSHTest NoPw"')
print(" Waiting for password prompt...")
t.wait_for_log("no stored auth", timeout=20)
time.sleep(2)
# 3. Screenshot: should show password dialog
img = t.screenshot("password_dialog")
t.verify("Password dialog shown", img,
"Is there a password dialog visible? It should show a 'Password' title, "
"a username@host subtitle, a password input field, a 'Remember password' checkbox, "
"and Connect/Disconnect buttons.")
# 4. Type password — we need to interact with the Compose dialog
# The password dialog is a Compose AlertDialog, not the terminal.
# ADB broadcast won't work here — it writes to the terminal session.
# We need to use ADB UI input instead.
print(" Typing password via ADB input...")
t.adb_shell(f"input text '{SSHTEST_PW}'")
time.sleep(1)
img = t.screenshot("password_typed")
t.verify("Password field filled", img,
"Is the password field filled (showing dots/bullets for masked characters)? "
"The Connect button should be visible.")
# 5. Dismiss keyboard and tap Connect button
print(" Pressing Connect...")
t.adb_shell("input keyevent KEYCODE_BACK") # dismiss keyboard
time.sleep(0.5)
# Tap Connect button (right side of dialog, ~540,840 on 720x1280 screen)
t.adb_shell("input tap 550 840")
time.sleep(3)
# 6. Wait for connection
print(" Waiting for connection...")
t.wait_for_log("state: Connected", timeout=15)
time.sleep(2)
img = t.screenshot("connected_nopw")
t.verify("Terminal connected after password", img,
"Is this a connected SSH terminal showing a shell prompt? "
"It should NOT show the password dialog anymore.")
t.assert_log("Session connected", "state: Connected")
# 7. Run ls -l to verify session works
t.set_font_size(7)
time.sleep(0.5)
t.send_text("ls -l")
t.send_enter()
time.sleep(2)
img = t.screenshot("ls_output")
t.verify("ls -l output visible", img,
"Does the terminal show a detailed file listing from 'ls -l'?")
# 8. Exit the session
print(" Exiting session...")
t.send_text("exit")
t.send_enter()
time.sleep(2)
img = t.screenshot("after_exit")
t.verify("Session disconnected", img,
"Does the screen show a disconnected state (disconnect bar with Close/Reconnect) "
"or the connection list?")
# 9. Reconnect with same profile — should prompt for password again (not saved)
print(" Reconnecting — should prompt for password again...")
t.adb_shell(f"rm -f {t.LOG_PATH}")
time.sleep(0.5)
t.force_stop()
time.sleep(1)
t.launch('--es profile "SSHTest NoPw"')
t.wait_for_log("no stored auth", timeout=20)
time.sleep(2)
img = t.screenshot("password_dialog_again")
t.verify("Password dialog shown again", img,
"Is the password dialog shown again? Since we did NOT check 'Remember password' "
"last time, it should prompt again.")
# 10. Type password AND check Remember
print(" Typing password + checking Remember...")
t.adb_shell(f"input text '{SSHTEST_PW}'")
time.sleep(0.5)
# Tap the Remember password checkbox (~140,720 based on dialog layout)
t.adb_shell("input keyevent KEYCODE_BACK") # dismiss keyboard first
time.sleep(0.5)
t.adb_shell("input tap 140 720") # checkbox
time.sleep(0.5)
img = t.screenshot("remember_checked")
t.verify("Remember password checked", img,
"Is the 'Remember password' checkbox checked/ticked? The password field should "
"also be filled.")
# 11. Tap Connect
print(" Pressing Connect...")
t.adb_shell("input tap 550 840") # Connect button
time.sleep(3)
t.wait_for_log("state: Connected", timeout=15)
time.sleep(2)
img = t.screenshot("connected_remembered")
t.verify("Connected with remembered password", img,
"Is this a connected SSH terminal showing a shell prompt?")
# 12. Exit and reconnect — this time should NOT prompt (password saved)
print(" Exiting and reconnecting — should auto-connect...")
t.send_text("exit")
t.send_enter()
time.sleep(2)
t.adb_shell(f"rm -f {t.LOG_PATH}")
time.sleep(0.5)
t.force_stop()
time.sleep(1)
t.launch('--es profile "SSHTest NoPw"')
t.wait_for_log("state: Connected", timeout=20)
time.sleep(2)
img = t.screenshot("auto_connected")
t.verify("Auto-connected without password prompt", img,
"Is this a connected SSH terminal? It should have connected automatically "
"without showing a password dialog, because the password was saved.")
t.assert_log("Password was used from store", "password")
# 13. Clean up — clear the saved password so test is repeatable
print(" Cleaning up — clearing saved password...")
t.send_text("exit")
t.send_enter()
time.sleep(2)
t.clear_password("SSHTest NoPw")
t.assert_log("Password cleared", "clearPasswordByName: deleted")
t.pull_log()
ok = t.summary()
sys.exit(0 if ok else 1)

View file

@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Test: Multiple sessions — open terminals + SFTP, switch, background/resume, close."""
import sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "test_lib"))
from common import TestRunner
t = TestRunner()
t.begin("multi_session")
# Tab bar geometry (720px wide screen, 2x density, 135dp = ~270px per tab)
# Tab label center (safe tap zone for switching):
TAB1_X = 67 # center of first tab
TAB2_X = 202 # center of second tab
TAB3_X = 337 # center of third tab (SFTP)
TAB_Y = 88 # tab bar vertical center
# Overflow dots (3-dot menu, right edge of each tab):
TAB1_DOTS_X = 125
TAB2_DOTS_X = 260
TAB3_DOTS_X = 395
# Overflow menu items (y positions, each ~96px apart from ~y=130):
MENU_ITEM_1_Y = 145 # Duplicate
MENU_ITEM_2_Y = 240 # Connect via SFTP / SFTP
MENU_ITEM_3_Y = 335 # Rename
MENU_ITEM_4_Y = 430 # Theme
MENU_ITEM_5_Y = 525 # Close
MENU_X = 200 # horizontal center of menu
PLUS_X = 660 # + button
def tap(x, y, delay=0.5):
t.adb_shell(f"input tap {x} {y}")
time.sleep(delay)
def dismiss_menu():
"""Dismiss any open menu by tapping empty terminal area."""
tap(360, 700, 0.3)
# Clean start
t.force_stop()
t.adb_shell(f"rm -f {t.LOG_PATH}")
time.sleep(0.5)
# --- Session 1 ---
print(" Launching first session...")
t.launch_profile("SSHTest", clear_log=False)
t.wait_for_log("Connected visible=true", timeout=20)
t.set_font_size(7)
time.sleep(1)
t.send_text("echo SESSION_1_MARKER")
t.send_enter()
time.sleep(1)
img = t.screenshot("session1_active")
t.verify("Session 1 connected", img,
"Is a connected terminal showing SESSION_1_MARKER output? "
"Tab bar should show one 'SSHTest' tab.")
# --- Session 2: open via + button ---
print(" Opening second session via + button...")
tap(PLUS_X, TAB_Y, 2)
img = t.screenshot("connection_list")
t.verify("Connection list shown", img,
"Is this the connection list showing saved connections?")
# Tap SSHTest connection card
print(" Tapping SSHTest to open second session...")
tap(360, 350, 1)
# May show context menu with "New Session" — tap it
tap(360, 450, 3)
t.wait_for_log("switchToTerminal session=2", timeout=10)
time.sleep(2)
t.send_text("echo SESSION_2_MARKER")
t.send_enter()
time.sleep(1)
img = t.screenshot("session2_active")
t.verify("Session 2 connected", img,
"Is a second terminal showing SESSION_2_MARKER? "
"Tab bar should show TWO tabs.")
# --- Switch between tabs ---
print(" Switching to session 1...")
tap(TAB1_X, TAB_Y, 1)
img = t.screenshot("back_to_session1")
t.verify("Switched to session 1", img,
"Does the terminal show SESSION_1_MARKER from earlier?")
print(" Switching to session 2...")
tap(TAB2_X, TAB_Y, 1)
img = t.screenshot("back_to_session2")
t.verify("Switched to session 2", img,
"Does the terminal show SESSION_2_MARKER?")
# --- Open SFTP via overflow menu ---
print(" Opening SFTP session...")
tap(TAB2_DOTS_X, TAB_Y, 1) # overflow dots on tab 2
img = t.screenshot("tab_overflow_menu")
t.verify("Overflow menu visible", img,
"Is a dropdown menu showing options including 'Connect via SFTP'?")
tap(MENU_X, MENU_ITEM_2_Y, 4) # "Connect via SFTP"
img = t.screenshot("sftp_opened")
t.verify("SFTP tab opened", img,
"Is an SFTP file browser visible with folders/files? "
"Or is there a new amber-colored tab in the tab bar?")
# --- Interact with SFTP ---
time.sleep(1)
img = t.screenshot("sftp_content")
t.verify("SFTP shows files", img,
"Does the screen show an SFTP file browser with directory entries?")
# --- Switch back to terminal 1 ---
print(" Switching back to terminal session 1...")
tap(TAB1_X, TAB_Y, 1)
img = t.screenshot("terminal_after_sftp")
t.verify("Session 1 still alive after SFTP", img,
"Does the terminal show SESSION_1_MARKER? Session survived SFTP switching.")
# --- Background the app ---
print(" Pressing Home to background app...")
t.adb_shell("input keyevent KEYCODE_HOME")
time.sleep(3)
img = t.screenshot("home_screen")
t.verify("App backgrounded", img,
"Is this the Android home screen or launcher? SSH Workbench should be in the background.")
# --- Resume from recents ---
print(" Reopening app from recents...")
t.adb_shell("input keyevent KEYCODE_APP_SWITCH")
time.sleep(2)
tap(360, 640, 2) # tap the recent app card
img = t.screenshot("app_resumed")
t.verify("App resumed with sessions", img,
"Is SSH Workbench back with a terminal visible? "
"Tab bar should still show multiple tabs.")
# --- Verify sessions survived ---
print(" Verifying sessions survived...")
tap(TAB1_X, TAB_Y, 1)
t.send_text("echo STILL_ALIVE_1")
t.send_enter()
time.sleep(1)
img = t.screenshot("session1_survived")
t.verify("Session 1 survived background", img,
"Does the terminal show STILL_ALIVE_1 output?")
tap(TAB2_X, TAB_Y, 1)
t.send_text("echo STILL_ALIVE_2")
t.send_enter()
time.sleep(1)
img = t.screenshot("session2_survived")
t.verify("Session 2 survived background", img,
"Does the terminal show STILL_ALIVE_2 output?")
# --- Close session 2 via overflow → Close ---
print(" Closing session 2...")
tap(TAB2_DOTS_X, TAB_Y, 1) # overflow dots on tab 2
time.sleep(0.5)
img = t.screenshot("close_menu")
# Close is the last item in the menu
tap(MENU_X, MENU_ITEM_5_Y, 2)
img = t.screenshot("after_close")
t.verify("Session 2 closed", img,
"Is session 2 gone from the tab bar? Should show fewer tabs than before.")
# --- Final check on remaining session ---
t.send_text("echo FINAL_CHECK")
t.send_enter()
time.sleep(1)
img = t.screenshot("final_check")
t.verify("Remaining session works", img,
"Does the terminal show FINAL_CHECK output? The remaining session is healthy.")
t.assert_log("Sessions survived backgrounding", "state: Connected")
t.pull_log()
ok = t.summary()
sys.exit(0 if ok else 1)

Binary file not shown.

Binary file not shown.