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:
parent
bb7662ca63
commit
2a87fb58d1
35 changed files with 3942 additions and 2116 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
9
docs/GLOSSARY.md
Normal 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
141
docs/KEYBOARD.md
Normal 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
204
docs/TESTING_ADB.md
Normal 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
59
scripts/test.py
Executable 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
87
scripts/test.sh
Executable 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
|
||||
BIN
scripts/test_lib/__pycache__/common.cpython-312.pyc
Normal file
BIN
scripts/test_lib/__pycache__/common.cpython-312.pyc
Normal file
Binary file not shown.
211
scripts/test_lib/common.py
Normal file
211
scripts/test_lib/common.py
Normal 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
|
||||
66
scripts/tests/01_connect_sshtest.py
Executable file
66
scripts/tests/01_connect_sshtest.py
Executable 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
99
scripts/tests/02_htop.py
Normal 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)
|
||||
165
scripts/tests/03_vim_edit.py
Normal file
165
scripts/tests/03_vim_edit.py
Normal 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)
|
||||
167
scripts/tests/04_password_prompt.py
Normal file
167
scripts/tests/04_password_prompt.py
Normal 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)
|
||||
198
scripts/tests/05_multi_session.py
Normal file
198
scripts/tests/05_multi_session.py
Normal 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)
|
||||
BIN
scripts/tests/__pycache__/01_connect_sshtest.cpython-312.pyc
Normal file
BIN
scripts/tests/__pycache__/01_connect_sshtest.cpython-312.pyc
Normal file
Binary file not shown.
BIN
scripts/tests/__pycache__/02_htop.cpython-312.pyc
Normal file
BIN
scripts/tests/__pycache__/02_htop.cpython-312.pyc
Normal file
Binary file not shown.
BIN
scripts/tests/__pycache__/03_vim_edit.cpython-312.pyc
Normal file
BIN
scripts/tests/__pycache__/03_vim_edit.cpython-312.pyc
Normal file
Binary file not shown.
BIN
scripts/tests/__pycache__/04_password_prompt.cpython-312.pyc
Normal file
BIN
scripts/tests/__pycache__/04_password_prompt.cpython-312.pyc
Normal file
Binary file not shown.
BIN
scripts/tests/__pycache__/05_multi_session.cpython-312.pyc
Normal file
BIN
scripts/tests/__pycache__/05_multi_session.cpython-312.pyc
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue