This commit is contained in:
23 files changed
+2224
-53
No files matched your search
@@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsChatPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsLanguagePage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsVaultPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
||||
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -137,6 +138,11 @@ private fun appEntryProvider(
|
||||
viewModel = viewModel { SettingsViewModel() },
|
||||
)
|
||||
}
|
||||
entry<SettingsVault> {
|
||||
SettingsVaultPage(
|
||||
onBack = { NavigationController.goBack() },
|
||||
)
|
||||
}
|
||||
entry<SettingsAbout> {
|
||||
SettingsAboutPage(
|
||||
onBack = { NavigationController.goBack() },
|
||||
|
||||
@@ -87,4 +87,6 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object SettingsBehavior : NavKey
|
||||
|
||||
@Serializable data object SettingsVault : NavKey
|
||||
|
||||
@Serializable data object SettingsAbout : NavKey
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Models for the Credential Vault RPC surface (issue #1090).
|
||||
*
|
||||
* Backend: `tui_gateway/methods_vault.py`.
|
||||
* Sources represent local encrypted vault or external managers (1Password, Bitwarden).
|
||||
*/
|
||||
|
||||
@Serializable
|
||||
data class VaultSource(
|
||||
val name: String,
|
||||
@SerialName("display_name") val displayName: String = name,
|
||||
val enabled: Boolean = false,
|
||||
@SerialName("needs_unlock") val needsUnlock: Boolean = false,
|
||||
val unlocked: Boolean = false,
|
||||
val installed: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultSourcesResponse(
|
||||
val sources: List<VaultSource> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultItem(
|
||||
val id: String,
|
||||
val backend: String = "local",
|
||||
val kind: String = "login",
|
||||
val label: String = "",
|
||||
val origin: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("identifier_type") val identifierType: String? = null,
|
||||
val identifier: String? = null,
|
||||
@SerialName("has_otp") val hasOtp: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultListResponse(
|
||||
val items: List<VaultItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultSourceSetResponse(
|
||||
val name: String? = null,
|
||||
val enabled: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultUnlockResponse(
|
||||
val name: String? = null,
|
||||
val unlocked: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultLockResponse(
|
||||
val locked: Boolean? = null,
|
||||
)
|
||||
@@ -341,6 +341,43 @@ object EventParser {
|
||||
WsEvent.SecretExpire(requestId, sessionId)
|
||||
}
|
||||
|
||||
// ── Credential Vault prompts (issue #1090) ──────────────────
|
||||
"vault.unlock.request" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
val backend = payload?.get("backend") as? String
|
||||
val displayName = payload?.get("display_name") as? String
|
||||
WsEvent.VaultUnlockRequest(requestId, sessionId, backend, displayName)
|
||||
}
|
||||
|
||||
"vault.unlock.expire" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
WsEvent.VaultUnlockExpire(requestId, sessionId)
|
||||
}
|
||||
|
||||
"vault.save_login.request" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
val origin = payload?.get("origin") as? String
|
||||
val site = payload?.get("site") as? String
|
||||
WsEvent.VaultSaveLoginRequest(requestId, sessionId, origin, site)
|
||||
}
|
||||
|
||||
"vault.save_login.expire" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
WsEvent.VaultSaveLoginExpire(requestId, sessionId)
|
||||
}
|
||||
|
||||
"vault.code.request" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
val site = payload?.get("site") as? String
|
||||
val hint = payload?.get("hint") as? String
|
||||
WsEvent.VaultCodeRequest(requestId, sessionId, site, hint)
|
||||
}
|
||||
|
||||
"vault.code.expire" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
WsEvent.VaultCodeExpire(requestId, sessionId)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown event type: $eventType")
|
||||
WsEvent.Unknown(rawJson)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.VaultItem
|
||||
import com.m57.hermescontrol.data.model.VaultListResponse
|
||||
import com.m57.hermescontrol.data.model.VaultLockResponse
|
||||
import com.m57.hermescontrol.data.model.VaultSource
|
||||
import com.m57.hermescontrol.data.model.VaultSourceSetResponse
|
||||
import com.m57.hermescontrol.data.model.VaultSourcesResponse
|
||||
import com.m57.hermescontrol.data.model.VaultUnlockResponse
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.serializer
|
||||
|
||||
/**
|
||||
* Repository for Credential Vault RPC operations over WebSocket (issue #1090).
|
||||
*/
|
||||
object VaultRepository {
|
||||
private val json get() = OkHttpProvider.json
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> decode(result: Any?): T? {
|
||||
if (result == null) return null
|
||||
val element: JsonElement =
|
||||
when (result) {
|
||||
is JsonElement -> result
|
||||
is Map<*, *> -> anyToJsonElement(result)
|
||||
is List<*> -> JsonArray(result.map { anyToJsonElement(it) })
|
||||
else -> JsonPrimitive(result.toString())
|
||||
}
|
||||
return json.decodeFromJsonElement(serializer<T>(), element)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun anyToJsonElement(value: Any?): JsonElement =
|
||||
when (value) {
|
||||
null -> JsonNull
|
||||
is JsonElement -> value
|
||||
is Map<*, *> -> JsonObject((value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) })
|
||||
is List<*> -> JsonArray(value.map { anyToJsonElement(it) })
|
||||
is String -> JsonPrimitive(value)
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
is Number -> JsonPrimitive(value)
|
||||
else -> JsonPrimitive(value.toString())
|
||||
}
|
||||
|
||||
suspend fun getSources(): List<VaultSource> {
|
||||
val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await()
|
||||
return decode<VaultSourcesResponse>(result)?.sources ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun setSourceEnabled(
|
||||
name: String,
|
||||
enabled: Boolean,
|
||||
): Boolean {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.VAULT_SOURCE_SET,
|
||||
mapOf("name" to name, "enabled" to enabled),
|
||||
).await()
|
||||
return decode<VaultSourceSetResponse>(result)?.enabled == enabled
|
||||
}
|
||||
|
||||
suspend fun unlockSource(
|
||||
name: String,
|
||||
password: String,
|
||||
): Boolean {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.VAULT_UNLOCK,
|
||||
mapOf("name" to name, "password" to password),
|
||||
).await()
|
||||
return decode<VaultUnlockResponse>(result)?.unlocked == true
|
||||
}
|
||||
|
||||
suspend fun lockSource(name: String? = null): Boolean {
|
||||
val params = if (name != null) mapOf("name" to name) else emptyMap<String, Any>()
|
||||
val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await()
|
||||
return decode<VaultLockResponse>(result)?.locked == true
|
||||
}
|
||||
|
||||
suspend fun listItems(): List<VaultItem> {
|
||||
val result = HermesWsClient.request(WsMethods.VAULT_LIST).await()
|
||||
return decode<VaultListResponse>(result)?.items ?: emptyList()
|
||||
}
|
||||
}
|
||||
@@ -303,6 +303,44 @@ sealed class WsEvent {
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Credential Vault prompts (issue #1090) ──────────────────────────
|
||||
|
||||
data class VaultUnlockRequest(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
val backend: String? = null,
|
||||
val displayName: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultUnlockExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultSaveLoginRequest(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
val origin: String? = null,
|
||||
val site: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultSaveLoginExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultCodeRequest(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
val site: String? = null,
|
||||
val hint: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultCodeExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Gateway-level errors ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,16 @@ object WsMethods {
|
||||
const val SUDO_RESPOND = "sudo.respond"
|
||||
const val SECRET_RESPOND = "secret.respond"
|
||||
|
||||
// ── Credential Vault (issue #1090) ────────────────────────────────────
|
||||
const val VAULT_UNLOCK_RESPOND = "vault.unlock.respond"
|
||||
const val VAULT_SAVE_LOGIN_RESPOND = "vault.save_login.respond"
|
||||
const val VAULT_CODE_RESPOND = "vault.code.respond"
|
||||
const val VAULT_LIST = "vault.list"
|
||||
const val VAULT_SOURCES = "vault.sources"
|
||||
const val VAULT_SOURCE_SET = "vault.source.set"
|
||||
const val VAULT_UNLOCK = "vault.unlock"
|
||||
const val VAULT_LOCK = "vault.lock"
|
||||
|
||||
// ── Heartbeat & Latency (issue #1017) ──────────────────────────────────
|
||||
|
||||
/** Ultra-lightweight liveness check and round-trip latency measurement. */
|
||||
|
||||
@@ -140,6 +140,13 @@ class ChatNotificationService : Service() {
|
||||
showReplyNotification(preview.take(100), null)
|
||||
}
|
||||
|
||||
is WsEvent.VaultUnlockRequest,
|
||||
is WsEvent.VaultSaveLoginRequest,
|
||||
is WsEvent.VaultCodeRequest,
|
||||
-> {
|
||||
showReplyNotification(getString(R.string.notif_input_needed), null)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Owns sudo and secret prompt state, timeouts, dismissal, and response RPCs.
|
||||
@@ -177,4 +179,234 @@ class ChatCredentialPromptsDelegate(
|
||||
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vault prompts (issue #1090) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The agent needs to unlock a password manager (1Password, Bitwarden).
|
||||
* Surface an interactive prompt card in chat and reply via vault.unlock.respond.
|
||||
*/
|
||||
fun handleVaultUnlockRequest(event: WsEvent.VaultUnlockRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
vaultUnlockPrompt =
|
||||
VaultUnlockPromptUi(
|
||||
requestId = event.requestId,
|
||||
sessionId = event.sessionId,
|
||||
backend = event.backend,
|
||||
displayName = event.displayName,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.vaultUnlockPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(vaultUnlockPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel/dismiss → send empty password (keeps manager locked, unblocks turn).
|
||||
*/
|
||||
fun dismissVaultUnlock() {
|
||||
val prompt = uiState.value.vaultUnlockPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
|
||||
uiState.update { it.copy(vaultUnlockPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_UNLOCK_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
fun respondToVaultUnlock(password: String) {
|
||||
val prompt = uiState.value.vaultUnlockPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
|
||||
if (password.isBlank()) return
|
||||
|
||||
uiState.update { it.copy(vaultUnlockPrompt = null) }
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to password,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_UNLOCK_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent captured credentials on a login page and asks whether to store them in the vault.
|
||||
*/
|
||||
fun handleVaultSaveLoginRequest(event: WsEvent.VaultSaveLoginRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
vaultSaveLoginPrompt =
|
||||
VaultSaveLoginPromptUi(
|
||||
requestId = event.requestId,
|
||||
sessionId = event.sessionId,
|
||||
origin = event.origin,
|
||||
site = event.site,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.vaultSaveLoginPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(vaultSaveLoginPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline saving → send empty login (backend treats empty login as declined).
|
||||
*/
|
||||
fun dismissVaultSaveLogin() {
|
||||
val prompt = uiState.value.vaultSaveLoginPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
|
||||
uiState.update { it.copy(vaultSaveLoginPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"login" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
fun respondToVaultSaveLogin(
|
||||
identifier: String,
|
||||
password: String,
|
||||
) {
|
||||
val prompt = uiState.value.vaultSaveLoginPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
|
||||
if (identifier.isBlank() || password.isBlank()) return
|
||||
|
||||
uiState.update { it.copy(vaultSaveLoginPrompt = null) }
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val loginJson =
|
||||
buildJsonObject {
|
||||
put("identifier", identifier)
|
||||
put("password", password)
|
||||
}.toString()
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"login" to loginJson,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The site requested a 2FA/MFA verification code.
|
||||
*/
|
||||
fun handleVaultCodeRequest(event: WsEvent.VaultCodeRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
vaultCodePrompt =
|
||||
VaultCodePromptUi(
|
||||
requestId = event.requestId,
|
||||
sessionId = event.sessionId,
|
||||
site = event.site,
|
||||
hint = event.hint,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.vaultCodePrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(vaultCodePrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip entering code → send empty code.
|
||||
*/
|
||||
fun dismissVaultCode() {
|
||||
val prompt = uiState.value.vaultCodePrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
|
||||
uiState.update { it.copy(vaultCodePrompt = null) }
|
||||
if (sessionId == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"code" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_CODE_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
fun respondToVaultCode(code: String) {
|
||||
val prompt = uiState.value.vaultCodePrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
|
||||
if (code.isBlank()) return
|
||||
|
||||
uiState.update { it.copy(vaultCodePrompt = null) }
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"code" to code,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_CODE_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.chat
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -549,6 +548,15 @@ fun ChatScreen(
|
||||
onRespondClarify = viewModel::respondToClarify,
|
||||
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
||||
onDismissClarify = viewModel::dismissClarify,
|
||||
vaultUnlockPrompt = state.vaultUnlockPrompt,
|
||||
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
|
||||
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
|
||||
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt,
|
||||
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
|
||||
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
|
||||
vaultCodePrompt = state.vaultCodePrompt,
|
||||
onRespondVaultCode = viewModel::respondToVaultCode,
|
||||
onDismissVaultCode = viewModel::dismissVaultCode,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||
openingAttachmentPath = state.openingAttachmentPath,
|
||||
|
||||
@@ -98,6 +98,10 @@ data class ChatUiState(
|
||||
// Sudo / secret prompts — surfaced as dialogs (issue #524)
|
||||
val sudoPrompt: SudoPromptUi? = null,
|
||||
val secretPrompt: SecretPromptUi? = null,
|
||||
// Credential vault prompts — interactive prompt cards (issue #1090)
|
||||
val vaultUnlockPrompt: VaultUnlockPromptUi? = null,
|
||||
val vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null,
|
||||
val vaultCodePrompt: VaultCodePromptUi? = null,
|
||||
val showSessionPicker: Boolean = false,
|
||||
// /update confirm dialog (issue #862) — the command is handled client-side
|
||||
val updateConfirmOpen: Boolean = false,
|
||||
@@ -251,6 +255,30 @@ data class SecretPromptUi(
|
||||
val prompt: String? = null,
|
||||
)
|
||||
|
||||
/** Transient — not persisted. Holds a pending vault unlock request (issue #1090). */
|
||||
data class VaultUnlockPromptUi(
|
||||
val requestId: String?,
|
||||
val sessionId: String?,
|
||||
val backend: String? = null,
|
||||
val displayName: String? = null,
|
||||
)
|
||||
|
||||
/** Transient — not persisted. Holds a pending vault save login request (issue #1090). */
|
||||
data class VaultSaveLoginPromptUi(
|
||||
val requestId: String?,
|
||||
val sessionId: String?,
|
||||
val origin: String? = null,
|
||||
val site: String? = null,
|
||||
)
|
||||
|
||||
/** Transient — not persisted. Holds a pending vault 2FA/MFA code request (issue #1090). */
|
||||
data class VaultCodePromptUi(
|
||||
val requestId: String?,
|
||||
val sessionId: String?,
|
||||
val site: String? = null,
|
||||
val hint: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Token breakdown backing the context meter's detail sheet. All values are
|
||||
* cumulative lifetime token counts sourced from `GET /api/sessions/{id}`
|
||||
@@ -882,6 +910,30 @@ class ChatViewModel(
|
||||
credentialPromptsDelegate.handleSecretExpire(event)
|
||||
}
|
||||
|
||||
is WsEvent.VaultUnlockRequest -> {
|
||||
credentialPromptsDelegate.handleVaultUnlockRequest(event)
|
||||
}
|
||||
|
||||
is WsEvent.VaultUnlockExpire -> {
|
||||
credentialPromptsDelegate.handleVaultUnlockExpire(event)
|
||||
}
|
||||
|
||||
is WsEvent.VaultSaveLoginRequest -> {
|
||||
credentialPromptsDelegate.handleVaultSaveLoginRequest(event)
|
||||
}
|
||||
|
||||
is WsEvent.VaultSaveLoginExpire -> {
|
||||
credentialPromptsDelegate.handleVaultSaveLoginExpire(event)
|
||||
}
|
||||
|
||||
is WsEvent.VaultCodeRequest -> {
|
||||
credentialPromptsDelegate.handleVaultCodeRequest(event)
|
||||
}
|
||||
|
||||
is WsEvent.VaultCodeExpire -> {
|
||||
credentialPromptsDelegate.handleVaultCodeExpire(event)
|
||||
}
|
||||
|
||||
is WsEvent.GatewayError -> {
|
||||
// Reducer already set errorMessage; no extra VM work needed.
|
||||
}
|
||||
@@ -3251,6 +3303,23 @@ class ChatViewModel(
|
||||
|
||||
fun respondToSecret(value: String) = credentialPromptsDelegate.respondToSecret(value)
|
||||
|
||||
// ── Vault prompt flow (issue #1090) ──────────────────────────────────
|
||||
|
||||
fun dismissVaultUnlock() = credentialPromptsDelegate.dismissVaultUnlock()
|
||||
|
||||
fun respondToVaultUnlock(password: String) = credentialPromptsDelegate.respondToVaultUnlock(password)
|
||||
|
||||
fun dismissVaultSaveLogin() = credentialPromptsDelegate.dismissVaultSaveLogin()
|
||||
|
||||
fun respondToVaultSaveLogin(
|
||||
identifier: String,
|
||||
password: String,
|
||||
) = credentialPromptsDelegate.respondToVaultSaveLogin(identifier, password)
|
||||
|
||||
fun dismissVaultCode() = credentialPromptsDelegate.dismissVaultCode()
|
||||
|
||||
fun respondToVaultCode(code: String) = credentialPromptsDelegate.respondToVaultCode(code)
|
||||
|
||||
fun reconnect() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
|
||||
@@ -75,6 +75,12 @@ object ChatWsEventReducer {
|
||||
is WsEvent.BtwComplete -> event.sessionId
|
||||
is WsEvent.SessionUsage -> event.sessionId
|
||||
is WsEvent.TranscriptResyncRequired -> event.sessionId
|
||||
is WsEvent.VaultUnlockRequest -> event.sessionId
|
||||
is WsEvent.VaultUnlockExpire -> event.sessionId
|
||||
is WsEvent.VaultSaveLoginRequest -> event.sessionId
|
||||
is WsEvent.VaultSaveLoginExpire -> event.sessionId
|
||||
is WsEvent.VaultCodeRequest -> event.sessionId
|
||||
is WsEvent.VaultCodeExpire -> event.sessionId
|
||||
else -> null
|
||||
}
|
||||
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
||||
@@ -170,6 +176,15 @@ object ChatWsEventReducer {
|
||||
|
||||
is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
// Vault prompts are handled by the ViewModel (issue #1090)
|
||||
is WsEvent.VaultUnlockRequest,
|
||||
is WsEvent.VaultUnlockExpire,
|
||||
is WsEvent.VaultSaveLoginRequest,
|
||||
is WsEvent.VaultSaveLoginExpire,
|
||||
is WsEvent.VaultCodeRequest,
|
||||
is WsEvent.VaultCodeExpire,
|
||||
-> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
|
||||
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Pin
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||
|
||||
/**
|
||||
* Interactive prompt cards for mid-turn Credential Vault operations (issue #1090).
|
||||
* Rendered inline in chat (matching ClarifyBubble pattern) to avoid blocking dialog scrims.
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun VaultUnlockCard(
|
||||
prompt: VaultUnlockPromptUi,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var password by remember(prompt.requestId) { mutableStateOf("") }
|
||||
var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) }
|
||||
|
||||
val displayName = prompt.displayName ?: prompt.backend ?: "Password Manager"
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.testTag("vault_unlock_card"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.vault_unlock_title, displayName),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.vault_unlock_desc, displayName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.vault_unlock_password_label)) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("vault_unlock_password_input"),
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (passwordVisible) {
|
||||
Icons.Filled.VisibilityOff
|
||||
} else {
|
||||
Icons.Filled.Visibility
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag("vault_unlock_dismiss"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_unlock_dismiss))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (password.isNotBlank()) {
|
||||
onConfirm(password)
|
||||
}
|
||||
},
|
||||
enabled = password.isNotBlank(),
|
||||
modifier = Modifier.testTag("vault_unlock_confirm"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_unlock_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VaultSaveLoginCard(
|
||||
prompt: VaultSaveLoginPromptUi,
|
||||
onConfirm: (identifier: String, password: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var identifier by remember(prompt.requestId) { mutableStateOf("") }
|
||||
var password by remember(prompt.requestId) { mutableStateOf("") }
|
||||
var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) }
|
||||
|
||||
val siteLabel = prompt.site ?: prompt.origin ?: "this site"
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.testTag("vault_save_login_card"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Key,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.vault_save_login_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.vault_save_login_desc, siteLabel),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = identifier,
|
||||
onValueChange = { identifier = it },
|
||||
label = { Text(stringResource(R.string.vault_save_login_identifier)) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("vault_save_login_identifier_input"),
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Email,
|
||||
),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.vault_save_login_password)) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("vault_save_login_password_input"),
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (passwordVisible) {
|
||||
Icons.Filled.VisibilityOff
|
||||
} else {
|
||||
Icons.Filled.Visibility
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag("vault_save_login_dismiss"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_save_login_dismiss))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (identifier.isNotBlank() && password.isNotBlank()) {
|
||||
onConfirm(identifier.trim(), password)
|
||||
}
|
||||
},
|
||||
enabled = identifier.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.testTag("vault_save_login_confirm"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_save_login_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VaultCodeCard(
|
||||
prompt: VaultCodePromptUi,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var code by remember(prompt.requestId) { mutableStateOf("") }
|
||||
|
||||
val siteLabel = prompt.site ?: "site"
|
||||
val desc = prompt.hint?.takeIf { it.isNotBlank() } ?: stringResource(R.string.vault_code_desc, siteLabel)
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.testTag("vault_code_card"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Pin,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.vault_code_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it },
|
||||
label = { Text(stringResource(R.string.vault_code_label)) },
|
||||
placeholder = { Text("123456") },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("vault_code_input"),
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag("vault_code_dismiss"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_code_dismiss))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (code.isNotBlank()) {
|
||||
onConfirm(code.trim())
|
||||
}
|
||||
},
|
||||
enabled = code.isNotBlank(),
|
||||
modifier = Modifier.testTag("vault_code_confirm"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_code_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,15 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
import com.m57.hermescontrol.ui.chat.components.VaultCodeCard
|
||||
import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard
|
||||
import com.m57.hermescontrol.ui.chat.components.VaultUnlockCard
|
||||
import com.m57.hermescontrol.ui.chat.toolCallMilestones
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
|
||||
@@ -69,6 +75,15 @@ fun FullBleedChatList(
|
||||
onRespondClarify: ((String) -> Unit)? = null,
|
||||
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
|
||||
onDismissClarify: (() -> Unit)? = null,
|
||||
vaultUnlockPrompt: VaultUnlockPromptUi? = null,
|
||||
onRespondVaultUnlock: ((String) -> Unit)? = null,
|
||||
onDismissVaultUnlock: (() -> Unit)? = null,
|
||||
vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null,
|
||||
onRespondVaultSaveLogin: ((String, String) -> Unit)? = null,
|
||||
onDismissVaultSaveLogin: (() -> Unit)? = null,
|
||||
vaultCodePrompt: VaultCodePromptUi? = null,
|
||||
onRespondVaultCode: ((String) -> Unit)? = null,
|
||||
onDismissVaultCode: (() -> Unit)? = null,
|
||||
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
|
||||
savingAttachmentPath: String? = null,
|
||||
openingAttachmentPath: String? = null,
|
||||
@@ -294,6 +309,38 @@ fun FullBleedChatList(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultUnlockPrompt != null) {
|
||||
item(key = "vault_unlock_card") {
|
||||
VaultUnlockCard(
|
||||
prompt = vaultUnlockPrompt,
|
||||
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) },
|
||||
onDismiss = { onDismissVaultUnlock?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultSaveLoginPrompt != null) {
|
||||
item(key = "vault_save_login_card") {
|
||||
VaultSaveLoginCard(
|
||||
prompt = vaultSaveLoginPrompt,
|
||||
onConfirm = { identifier, password ->
|
||||
onRespondVaultSaveLogin?.invoke(identifier, password)
|
||||
},
|
||||
onDismiss = { onDismissVaultSaveLogin?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultCodePrompt != null) {
|
||||
item(key = "vault_code_card") {
|
||||
VaultCodeCard(
|
||||
prompt = vaultCodePrompt,
|
||||
onConfirm = { code -> onRespondVaultCode?.invoke(code) },
|
||||
onDismiss = { onDismissVaultCode?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.automirrored.filled.ListAlt
|
||||
import androidx.compose.material.icons.filled.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Card
|
||||
@@ -41,6 +42,7 @@ import com.m57.hermescontrol.SettingsBehavior
|
||||
import com.m57.hermescontrol.SettingsChat
|
||||
import com.m57.hermescontrol.SettingsConnection
|
||||
import com.m57.hermescontrol.SettingsLanguage
|
||||
import com.m57.hermescontrol.SettingsVault
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
@@ -118,6 +120,12 @@ fun SettingsScreen(
|
||||
},
|
||||
onClick = { NavigationController.navigateTo(SettingsBehavior) },
|
||||
),
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
label = stringResource(R.string.settings_sec_vault),
|
||||
summary = stringResource(R.string.settings_summary_vault),
|
||||
onClick = { NavigationController.navigateTo(SettingsVault) },
|
||||
),
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Info,
|
||||
label = stringResource(R.string.settings_sec_about),
|
||||
|
||||
@@ -15,8 +15,11 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -39,6 +42,9 @@ import com.m57.hermescontrol.ui.settings.components.ConnectionSection
|
||||
import com.m57.hermescontrol.ui.settings.components.LanguageSection
|
||||
import com.m57.hermescontrol.ui.settings.components.TestConnectionButton
|
||||
import com.m57.hermescontrol.ui.settings.components.TestResultCard
|
||||
import com.m57.hermescontrol.ui.settings.components.VaultItemsSection
|
||||
import com.m57.hermescontrol.ui.settings.components.VaultSourcesSection
|
||||
import com.m57.hermescontrol.ui.settings.components.VaultUnlockDialog
|
||||
|
||||
/**
|
||||
* Drill-down sub-pages for Settings. Each is its own NavKey destination
|
||||
@@ -288,3 +294,54 @@ internal fun SettingsAboutPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsVaultPage(
|
||||
onBack: () -> Unit,
|
||||
viewModel: SettingsVaultViewModel = viewModel { SettingsVaultViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(state.toastMessage) {
|
||||
state.toastMessage?.let { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
viewModel.clearToast()
|
||||
}
|
||||
}
|
||||
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.settings_sec_vault)) },
|
||||
navigationIcon = NavIcon.Back(onBack),
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
drawerGesturesEnabled = false,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
VaultSourcesSection(
|
||||
sources = state.sources,
|
||||
isActionInProgress = state.isActionInProgress,
|
||||
onToggleSource = viewModel::toggleSource,
|
||||
onUnlockClick = viewModel::showUnlockDialog,
|
||||
onLockClick = { source -> viewModel.lockSource(source.name) },
|
||||
onLockAllClick = { viewModel.lockSource(null) },
|
||||
)
|
||||
|
||||
VaultItemsSection(items = state.items)
|
||||
}
|
||||
|
||||
state.unlockDialogSource?.let { source ->
|
||||
VaultUnlockDialog(
|
||||
source = source,
|
||||
onConfirm = { password -> viewModel.unlockSource(source.name, password) },
|
||||
onDismiss = viewModel::dismissUnlockDialog,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.m57.hermescontrol.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.VaultItem
|
||||
import com.m57.hermescontrol.data.model.VaultSource
|
||||
import com.m57.hermescontrol.data.ws.VaultRepository
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SettingsVaultUiState(
|
||||
val sources: List<VaultSource> = emptyList(),
|
||||
val items: List<VaultItem> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isActionInProgress: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
val unlockDialogSource: VaultSource? = null,
|
||||
)
|
||||
|
||||
class SettingsVaultViewModel(
|
||||
private val vaultRepo: VaultRepository = VaultRepository,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SettingsVaultUiState())
|
||||
val uiState: StateFlow<SettingsVaultUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadVaultData()
|
||||
}
|
||||
|
||||
fun loadVaultData() {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
try {
|
||||
val sources = vaultRepo.getSources()
|
||||
val items =
|
||||
try {
|
||||
vaultRepo.listItems()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
sources = sources,
|
||||
items = items,
|
||||
isLoading = false,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = e.message ?: "Failed to load vault status",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSource(
|
||||
source: VaultSource,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
_uiState.update { it.copy(isActionInProgress = true) }
|
||||
try {
|
||||
vaultRepo.setSourceEnabled(source.name, enabled)
|
||||
val sources = vaultRepo.getSources()
|
||||
_uiState.update { it.copy(sources = sources, isActionInProgress = false) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionInProgress = false,
|
||||
toastMessage = "Failed to update ${source.displayName}: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unlockSource(
|
||||
name: String,
|
||||
password: String,
|
||||
) {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
_uiState.update { it.copy(isActionInProgress = true, unlockDialogSource = null) }
|
||||
try {
|
||||
val success = vaultRepo.unlockSource(name, password)
|
||||
val sources = vaultRepo.getSources()
|
||||
val items =
|
||||
try {
|
||||
vaultRepo.listItems()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
sources = sources,
|
||||
items = items,
|
||||
isActionInProgress = false,
|
||||
toastMessage = if (success) "Unlocked $name" else "Failed to unlock $name",
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionInProgress = false,
|
||||
toastMessage = "Error unlocking $name: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun lockSource(name: String? = null) {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
_uiState.update { it.copy(isActionInProgress = true) }
|
||||
try {
|
||||
vaultRepo.lockSource(name)
|
||||
val sources = vaultRepo.getSources()
|
||||
val items =
|
||||
try {
|
||||
vaultRepo.listItems()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
sources = sources,
|
||||
items = items,
|
||||
isActionInProgress = false,
|
||||
toastMessage = if (name != null) "Locked $name" else "All password managers locked",
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionInProgress = false,
|
||||
toastMessage = "Error locking vault: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showUnlockDialog(source: VaultSource) {
|
||||
_uiState.update { it.copy(unlockDialogSource = source) }
|
||||
}
|
||||
|
||||
fun dismissUnlockDialog() {
|
||||
_uiState.update { it.copy(unlockDialogSource = null) }
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package com.m57.hermescontrol.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.VaultItem
|
||||
import com.m57.hermescontrol.data.model.VaultSource
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
|
||||
@Composable
|
||||
fun VaultSourcesSection(
|
||||
sources: List<VaultSource>,
|
||||
isActionInProgress: Boolean,
|
||||
onToggleSource: (VaultSource, Boolean) -> Unit,
|
||||
onUnlockClick: (VaultSource) -> Unit,
|
||||
onLockClick: (VaultSource) -> Unit,
|
||||
onLockAllClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.vault_sources_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.vault_sources_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
sources.forEachIndexed { index, source ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
VaultSourceRow(
|
||||
source = source,
|
||||
isActionInProgress = isActionInProgress,
|
||||
onToggle = { onToggleSource(source, it) },
|
||||
onUnlock = { onUnlockClick(source) },
|
||||
onLock = { onLockClick(source) },
|
||||
)
|
||||
}
|
||||
|
||||
val hasUnlockedExternal = sources.any { it.needsUnlock && it.unlocked }
|
||||
if (hasUnlockedExternal) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = onLockAllClick,
|
||||
modifier = Modifier.fillMaxWidth().testTag("vault_lock_all_button"),
|
||||
enabled = !isActionInProgress,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.vault_lock_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VaultSourceRow(
|
||||
source: VaultSource,
|
||||
isActionInProgress: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onUnlock: () -> Unit,
|
||||
onLock: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = source.displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
if (source.name == "local") {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = LocalHermesStatusColors.current.success.copy(alpha = 0.15f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.vault_source_unlocked),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LocalHermesStatusColors.current.success,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
} else if (!source.installed) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.vault_source_not_installed),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
} else if (source.unlocked) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = LocalHermesStatusColors.current.success.copy(alpha = 0.15f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.vault_source_unlocked),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LocalHermesStatusColors.current.success,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.vault_source_locked),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (source.name != "local" && source.installed) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (source.unlocked) {
|
||||
FilledTonalButton(
|
||||
onClick = onLock,
|
||||
enabled = !isActionInProgress,
|
||||
modifier = Modifier.height(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.vault_source_lock),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onUnlock,
|
||||
enabled = !isActionInProgress && source.enabled,
|
||||
modifier = Modifier.height(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.LockOpen,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.vault_source_unlock),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (source.name != "local" && source.installed) {
|
||||
Switch(
|
||||
checked = source.enabled,
|
||||
onCheckedChange = onToggle,
|
||||
enabled = !isActionInProgress,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VaultItemsSection(
|
||||
items: List<VaultItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "${stringResource(R.string.vault_items_title)} (${items.size})",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
if (items.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.vault_items_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
} else {
|
||||
items.forEachIndexed { index, item ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
VaultItemRow(item = item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VaultItemRow(item: VaultItem) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Key,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label.ifBlank { item.origin ?: item.id },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val subtext = item.identifier ?: item.origin
|
||||
if (!subtext.isNullOrBlank()) {
|
||||
Text(
|
||||
text = subtext,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Text(
|
||||
text = item.backend,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VaultUnlockDialog(
|
||||
source: VaultSource,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.vault_unlock_dialog_title, source.displayName)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.vault_unlock_desc, source.displayName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.vault_unlock_password_label)) },
|
||||
modifier = Modifier.fillMaxWidth().testTag("dialog_vault_password_input"),
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (passwordVisible) {
|
||||
Icons.Filled.VisibilityOff
|
||||
} else {
|
||||
Icons.Filled.Visibility
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (password.isNotBlank()) {
|
||||
onConfirm(password)
|
||||
}
|
||||
},
|
||||
enabled = password.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_unlock_dialog_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.chat_dismiss))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user