feat(#1288): account-owned connectors management and tool policy (#1302)

This commit is contained in:
m57 authored and GitHub committed 2026-09-27 13:22:49 +03:00
1 parent 7c09810b23
commit 3aedb44bed
19 files changed
+1557 -14

No files matched your search

@@ -60,7 +60,10 @@ internal class ConnectionResumeCheckpoint(
/** Session-scoped authoritative reducer for backend-owned connector operations. */
class ChatConnectionOperationDelegate(
private val requester: ConnectionOperationRequester,
private val accountOwned: Boolean,
) {
constructor(requester: ConnectionOperationRequester) : this(requester, false)
private val _state = MutableStateFlow(ConnectionOperationUiState())
val state: StateFlow<ConnectionOperationUiState> = _state.asStateFlow()
private var sessionId: String? = null
@@ -170,7 +173,7 @@ class ChatConnectionOperationDelegate(
method = WsMethods.CONNECTION_RESPOND,
params =
mapOf(
"owner" to ConnectorRepository.sessionOwner(requireSessionId()),
"owner" to ownerParams(),
"op_id" to snapshot.opId,
"result" to mapOf("targets" to listOf(answer)),
),
@@ -183,7 +186,7 @@ class ChatConnectionOperationDelegate(
method = WsMethods.CONNECTION_RESPOND,
params =
mapOf(
"owner" to ConnectorRepository.sessionOwner(requireSessionId()),
"owner" to ownerParams(),
"op_id" to snapshot.opId,
"result" to mapOf("settled_by" to "continue"),
),
@@ -196,7 +199,7 @@ class ChatConnectionOperationDelegate(
val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return
dispatch(
method = WsMethods.CONNECTORS_OPERATION_WAKE,
params = mapOf("owner" to ConnectorRepository.sessionOwner(requireSessionId()), "op_id" to snapshot.opId),
params = mapOf("owner" to ownerParams(), "op_id" to snapshot.opId),
clearOnSuccess = true,
unknownOperationSettles = true,
)
@@ -237,7 +240,7 @@ class ChatConnectionOperationDelegate(
@Synchronized
private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? {
val current = _state.value.operation ?: return null
if (sessionId.isNullOrBlank() ||
if ((!accountOwned && sessionId.isNullOrBlank()) ||
_state.value.pendingAction != null ||
current.opId != action.opId ||
current.seq != action.observedSeq
@@ -259,9 +262,15 @@ class ChatConnectionOperationDelegate(
}
private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean =
!sessionId.isNullOrBlank() && snapshot.sessionId == sessionId
if (accountOwned) {
snapshot.accountOwned
} else {
!snapshot.accountOwned && !sessionId.isNullOrBlank() &&
snapshot.sessionId == sessionId
}
private fun requireSessionId(): String = checkNotNull(sessionId?.takeIf { it.isNotBlank() })
private fun ownerParams(): Map<String, String> =
if (accountOwned) mapOf("type" to "account") else ConnectorRepository.sessionOwner(checkNotNull(sessionId))
private fun currentOp(): String =
_state.value.operation
@@ -179,7 +179,7 @@ internal fun ToolBubble(
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
// ── Header row: icon + tool name ──
HeaderRow(message, config, contentColor, statusColors)
HeaderRow(message, config, contentColor, statusColors, view?.serverDisplayName)
// ── Tool progress preview (tool.progress) ──
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
@@ -528,6 +528,7 @@ private fun HeaderRow(
config: ToolDisplayConfig,
contentColor: Color,
statusColors: HermesStatusColors,
displayName: String? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -562,7 +563,7 @@ private fun HeaderRow(
}
Text(
text = message.toolName ?: stringResource(R.string.chat_tool_fallback),
text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback),
style =
MaterialTheme.typography.labelMedium.copy(
color = contentColor,
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.ui.chat.tool.ToolView
import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -42,7 +43,15 @@ fun parseToolOutput(
// Extract args sub-object if present (new tool.complete format) and
// the result sub-object, falling back to the top-level payload (old
// format where result fields sat at the root).
val args = element["args"] as? JsonObject
val rawArgs = element["args"] as? JsonObject
// Gateway labels are envelope metadata; never interpret a real args.labels value as display text.
val labels = element["labels"] as? JsonArray
val args =
if (labels != null) {
JsonObject(rawArgs.orEmpty() + ("hermes_tool_labels" to labels))
} else {
rawArgs
}
val result = element["result"] as? JsonObject ?: element
ToolViewBuilder.build(
@@ -64,4 +64,5 @@ data class ToolView(
val searchQuery: String? = null,
/** Detected error text (destructive styling in the view). */
val error: String? = null,
val serverDisplayName: String? = null,
)
@@ -1,7 +1,10 @@
package com.m57.hermescontrol.ui.chat.tool
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/**
* The tool-display engine's orchestrator — a port of the desktop app's
@@ -48,12 +51,14 @@ object ToolViewBuilder {
ToolStatusResolver.errorText(call, isError)
}
val serverTitle = serverLabelTitle(call.args)
val title =
when {
running -> renderer.pendingTitle(call)
error.isNotEmpty() -> renderer.errorTitle(call)
else -> renderer.doneTitle(call)
} ?: ToolJson.titleForTool(toolName)
serverTitle
?: when {
running -> renderer.pendingTitle(call)
error.isNotEmpty() -> renderer.errorTitle(call)
else -> renderer.doneTitle(call)
} ?: ToolJson.titleForTool(toolName)
val subtitle =
if (error.isNotEmpty()) {
@@ -103,6 +108,7 @@ object ToolViewBuilder {
return ToolView(
status = status,
title = title,
serverDisplayName = serverTitle,
subtitle = subtitle,
detail = clampedDetail,
detailLabel = extras.detailLabel,
@@ -125,6 +131,20 @@ object ToolViewBuilder {
)
}
/** #1288: server display metadata, not the real tool's `labels` argument. */
internal fun serverLabelTitle(args: JsonObject?): String? =
(args?.get("hermes_tool_labels") as? JsonArray)
?.mapNotNull { entry ->
val row = entry as? JsonObject ?: return@mapNotNull null
val text =
(row["text"] as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank)
?: return@mapNotNull null
val app = (row["app"] as? JsonPrimitive)?.contentOrNull.orEmpty()
if (app.isNotBlank() && !text.contains(app, ignoreCase = true)) "$app · $text" else text
}?.distinct()
?.joinToString(" · ")
?.takeIf(String::isNotBlank)
/** Subtitle fallback: first line of the heuristic summary, then contexts. */
private fun genericSubtitle(call: ToolCall): String {
val summary = ToolResultSummary.formatToolResultSummary(call.rawResult)
@@ -0,0 +1,278 @@
package com.m57.hermescontrol.ui.connectors
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ConnectorAccount
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.util.ConnectorUrlValidator
@Composable
fun AccountConnectorsScreen(
onOpenDrawer: () -> Unit,
vm: AccountConnectorsViewModel = viewModel { AccountConnectorsViewModel() },
) {
val state by vm.state.collectAsStateWithLifecycle()
val connection by vm.connectionState.collectAsStateWithLifecycle()
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val spacing = LocalSpacing.current
var pendingRemoval by remember(state.scope) { mutableStateOf<ConnectorAccount?>(null) }
var browserOperation by remember(state.scope) { mutableStateOf<String?>(null) }
var departed by remember(state.scope) { mutableStateOf(false) }
DisposableEffect(lifecycleOwner, state.scope) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE && browserOperation != null) departed = true
if (event == Lifecycle.Event.ON_RESUME && departed) {
val opId = browserOperation
browserOperation = null
departed = false
ExternalActivityLifecycleGuard.externalActivityReturned()
if (opId != null) vm.onBrowserReturn(opId)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
if (browserOperation != null) ExternalActivityLifecycleGuard.externalActivityReturned()
}
}
HermesScaffold(
title = { Text(stringResource(R.string.account_connectors_title)) },
navigationIcon = NavIcon.Menu(onOpenDrawer),
onRefresh = vm::refresh,
) {
when {
state.loading -> {
LoadingState()
}
state.error != null && state.catalog.isEmpty() && state.accounts.isEmpty() -> {
ErrorState(message = state.error.orEmpty(), onRetry = vm::refresh)
}
state.catalog.isEmpty() && state.accounts.isEmpty() -> {
EmptyState(title = stringResource(R.string.account_connectors_no_accounts))
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(spacing.md),
verticalArrangement = Arrangement.spacedBy(spacing.md),
) {
state.error?.let { error -> item { Text(error, color = MaterialTheme.colorScheme.error) } }
items(state.catalog, key = { "catalog-${it.slug}" }) { connector ->
val current = state.connectorStates.firstOrNull { it.connector == connector.slug }
val policy = state.policy
val enabled = policy?.connectorEnabled(connector.slug) ?: current?.enabled
val locked = policy?.inherited?.any { !it.connectorEnabled(connector.slug) } == true
val editable = policy?.member != null && !state.busy && !locked
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.padding(spacing.md),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) {
Text(connector.name, style = MaterialTheme.typography.titleMedium)
if (connector.description.isNotBlank()) Text(connector.description)
Text(
current?.connectionStatus
?: stringResource(R.string.account_connectors_not_connected),
)
// Stack actions so large fonts and narrow phones never hide a control.
Button(
onClick = { vm.connect(connector.slug, reconnect = current?.isConnected == true) },
enabled = !state.busy && connection.operation == null,
) {
Text(
stringResource(
if (current?.isConnected ==
true
) {
R.string.account_connectors_reconnect
} else {
R.string.account_connectors_connect
},
),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = enabled == true,
enabled = editable,
onCheckedChange = { vm.setEnabled(connector.slug, it) },
)
Text(stringResource(R.string.account_connectors_enabled))
}
if (locked ||
policy?.member == null
) {
Text(stringResource(R.string.account_connectors_policy_locked))
}
OutlinedButton(
onClick = { vm.loadTools(connector.slug) },
enabled = connector.slug !in state.toolsLoading,
) { Text(stringResource(R.string.account_connectors_tools)) }
state.tools[connector.slug]?.let { tools ->
if (tools.isEmpty()) Text(stringResource(R.string.account_connectors_no_tools))
tools.forEach { tool ->
val disabled =
tool.slug in
policy
?.member
?.tools
?.get(connector.slug)
.orEmpty()
val toolLocked =
policy?.inherited?.any { !it.toolEnabled(connector.slug, tool) } == true
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = enabled == true && !disabled && !toolLocked,
enabled = editable && enabled == true && !toolLocked,
onCheckedChange = { checked ->
val updated =
state.policy
?.member
?.tools
?.get(
connector.slug,
).orEmpty()
.toMutableSet()
if (checked) updated.remove(tool.slug) else updated.add(tool.slug)
vm.setDisabledTools(connector.slug, updated.toList())
},
)
Column(Modifier.weight(1f)) {
Text(tool.name)
if (toolLocked) {
Text(
stringResource(R.string.account_connectors_policy_locked),
)
}
Text(tool.description, style = MaterialTheme.typography.bodySmall)
Text(tool.facet, style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
}
item {
Text(
stringResource(R.string.account_connectors_accounts),
style = MaterialTheme.typography.titleMedium,
)
}
items(state.accounts, key = { "account-${it.connectionId}" }) { account ->
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.padding(spacing.md),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) {
Text(account.label, style = MaterialTheme.typography.titleMedium)
Text(
state.catalog.firstOrNull { it.slug == account.connector }?.name
?: account.connector,
)
Text(account.status)
account.statusReason?.let { Text(it) }
account.alias?.let { Text(it) }
OutlinedButton(onClick = { pendingRemoval = account }, enabled = !state.busy) {
Text(stringResource(R.string.account_connectors_remove))
}
}
}
}
if (state.accounts.isEmpty()) item { Text(stringResource(R.string.account_connectors_no_accounts)) }
}
}
}
}
ConnectionSetupSheet(
state = connection,
onRespond = vm::respond,
onContinue = vm::continueOperation,
onDismiss = vm::continueOperation,
onOpenBrowser = { opId, url ->
if (ConnectorUrlValidator.isValidHttpsUrl(url)) {
try {
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease,
prepareForBackground = { NotificationHelper.start(context) },
cleanupAfterLaunchFailure = { NotificationHelper.stop(context) },
) {
browserOperation = opId
departed = false
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
} catch (_: Exception) {
browserOperation = null
ExternalActivityLifecycleGuard.externalActivityReturned()
vm.browserLaunchFailed()
}
}
},
)
pendingRemoval?.let { account ->
AlertDialog(
onDismissRequest = { pendingRemoval = null },
title = { Text(stringResource(R.string.account_connectors_remove_confirm_title)) },
text = { Text(stringResource(R.string.account_connectors_remove_confirm_message, account.label)) },
confirmButton = {
TextButton(onClick = {
vm.remove(account.connectionId)
pendingRemoval = null
}, enabled = !state.busy) { Text(stringResource(R.string.account_connectors_remove)) }
},
dismissButton = {
TextButton(
onClick = { pendingRemoval = null },
) { Text(stringResource(R.string.common_cancel)) }
},
)
}
}
@@ -0,0 +1,268 @@
package com.m57.hermescontrol.ui.connectors
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.model.AccountConnectorResult
import com.m57.hermescontrol.data.model.ConnectorAccount
import com.m57.hermescontrol.data.model.ConnectorCatalogEntry
import com.m57.hermescontrol.data.model.ConnectorItem
import com.m57.hermescontrol.data.model.ConnectorPolicy
import com.m57.hermescontrol.data.model.ConnectorTool
import com.m57.hermescontrol.data.ws.AccountConnectorRepository
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate
import com.m57.hermescontrol.ui.chat.ConnectionOperationRequester
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
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 AccountConnectorsState(
val loading: Boolean = true,
val error: String? = null,
val catalog: List<ConnectorCatalogEntry> = emptyList(),
val connectorStates: List<ConnectorItem> = emptyList(),
val accounts: List<ConnectorAccount> = emptyList(),
val tools: Map<String, List<ConnectorTool>> = emptyMap(),
val toolsLoading: Set<String> = emptySet(),
val policy: ConnectorPolicy? = null,
val busy: Boolean = false,
val scope: DataScope? = null,
)
class AccountConnectorsViewModel(
private val repository: AccountConnectorRepository = AccountConnectorRepository,
private val scopes: StateFlow<DataScope?> = AuthManager.dataScopeFlow,
private val currentScope: () -> DataScope? = AuthManager::currentDataScope,
events: Flow<WsEvent> = HermesWsClient.events,
) : ViewModel() {
private val _state = MutableStateFlow(AccountConnectorsState())
val state: StateFlow<AccountConnectorsState> = _state.asStateFlow()
private var generation = 0L
private var loadJob: Job? = null
private val scopedJobs = mutableListOf<Job>()
private val operation =
ChatConnectionOperationDelegate(
requester = ConnectionOperationRequester(repository::operationRequest),
accountOwned = true,
)
val connectionState = operation.state
init {
viewModelScope.launch {
scopes.collect { scope ->
generation++
loadJob?.cancel()
scopedJobs.forEach(Job::cancel)
scopedJobs.clear()
operation.reset()
_state.value = AccountConnectorsState(scope = scope)
if (scope != null) load() else _state.update { it.copy(loading = false) }
}
}
viewModelScope.launch {
events.collect { event ->
if (event is WsEvent.GatewayReady) refresh()
val snapshot = (event as? WsEvent.ConnectionUpdate)?.snapshot ?: return@collect
// Never adopt an unsolicited account event: only our acknowledged operation belongs here.
if (isCurrent(generation) && snapshot.accountOwned &&
snapshot.opId ==
operation.state.value.operation
?.opId && operation.acceptUpdate(snapshot)
) {
if (snapshot.settled) load()
}
}
}
}
private fun isCurrent(epoch: Long): Boolean = epoch == generation && _state.value.scope == currentScope()
fun refresh() {
if (!_state.value.busy) {
load()
if (connectionState.value.operation != null) operationAction { }
}
}
private fun load() {
loadJob?.cancel()
val epoch = generation
_state.update { it.copy(loading = true, error = null) }
loadJob =
viewModelScope.launch {
val list = async { repository.listConnectors() }
val catalog = async { repository.catalog() }
val accounts = async { repository.accounts() }
val policy = async { repository.policy() }
val listResult = list.await()
val catalogResult = catalog.await()
val accountsResult = accounts.await()
val policyResult = policy.await()
if (!isCurrent(epoch)) return@launch
_state.update {
it.copy(
loading = false,
error =
listResult.errorOrNull()?.message ?: catalogResult.errorMessage()
?: accountsResult.errorMessage() ?: policyResult.errorMessage(),
catalog = catalogResult.valueOrNull().orEmpty(),
connectorStates = listResult.getOrNull().orEmpty(),
accounts = accountsResult.valueOrNull().orEmpty(),
policy = policyResult.valueOrNull(),
)
}
}
}
fun loadTools(slug: String) {
scopedJobs.removeAll { it.isCompleted }
if (slug in _state.value.toolsLoading || !isCurrent(generation)) return
val epoch = generation
_state.update { it.copy(toolsLoading = it.toolsLoading + slug) }
scopedJobs +=
viewModelScope.launch {
val result = repository.tools(slug)
if (!isCurrent(epoch)) return@launch
_state.update {
it.copy(
tools = result.valueOrNull()?.let { tools -> it.tools + (slug to tools) } ?: it.tools,
toolsLoading = it.toolsLoading - slug,
error = result.errorMessage(),
)
}
}
}
fun connect(
slug: String,
reconnect: Boolean = false,
) = mutate {
val epoch = generation
val result = repository.connect(listOf(slug), reconnect)
if (isCurrent(epoch)) {
result.valueOrNull()?.let { snapshot ->
operation.acceptRequest(snapshot)
if (snapshot.settled) load()
}
}
result.errorMessage()
}
fun remove(connectionId: String) =
mutate {
val epoch = generation
val result = repository.remove(connectionId)
if (isCurrent(epoch) && result is AccountConnectorResult.Success) load()
result.errorMessage()
}
fun setEnabled(
slug: String,
enabled: Boolean,
) = updatePolicy {
repository.setConnectorEnabled(
slug,
enabled,
_state.value.policy
?.member
?.revision
.orEmpty(),
)
}
fun setDisabledTools(
slug: String,
disabled: List<String>,
) = updatePolicy {
repository.setDisabledTools(
slug,
disabled,
_state.value.policy
?.member
?.revision
.orEmpty(),
)
}
private fun updatePolicy(action: suspend () -> AccountConnectorResult<ConnectorPolicy>) =
mutate {
val epoch = generation
val result = action()
if (isCurrent(epoch)) result.valueOrNull()?.let { policy -> _state.update { it.copy(policy = policy) } }
result.errorMessage()
}
// Reserve synchronously, not inside launch: repeated taps must not start concurrent writes.
private fun mutate(action: suspend () -> String?) {
scopedJobs.removeAll { it.isCompleted }
if (_state.value.scope == null || _state.value.busy || _state.value.loading || !isCurrent(generation)) return
val epoch = generation
_state.update { it.copy(busy = true, error = null) }
scopedJobs +=
viewModelScope.launch {
try {
if (!isCurrent(epoch)) return@launch
val error = action()
if (isCurrent(epoch)) _state.update { it.copy(busy = false, error = error) }
} catch (e: CancellationException) {
throw e
} finally {
if (isCurrent(epoch)) _state.update { it.copy(busy = false) }
}
}
}
fun respond(
target: String,
env: Map<String, String>,
approved: Boolean,
) = operationAction {
operation.respond(target, env, approved)
}
fun continueOperation() = operationAction { operation.continueOperation() }
fun onBrowserReturn(opId: String) = operationAction { operation.wake(opId) }
private fun operationAction(action: suspend () -> Unit) {
scopedJobs.removeAll { it.isCompleted }
if (!isCurrent(generation)) return
val epoch = generation
val opId = connectionState.value.operation?.opId ?: return
scopedJobs +=
viewModelScope.launch {
action()
if (!isCurrent(epoch)) return@launch
val result = repository.operationStatus(opId)
if (!isCurrent(epoch)) return@launch
when (result) {
is AccountConnectorResult.Success -> {
operation.acceptUpdate(result.value)
if (result.value.settled) load()
}
is AccountConnectorResult.Failure -> {
_state.update { it.copy(error = result.error.message) }
}
}
}
}
fun browserLaunchFailed() {
_state.update { it.copy(error = "Unable to open the authorization browser.") }
}
}
private fun <T> AccountConnectorResult<T>.valueOrNull(): T? = (this as? AccountConnectorResult.Success)?.value
private fun AccountConnectorResult<*>.errorMessage(): String? =
(this as? AccountConnectorResult.Failure)?.error?.message
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.ListAlt
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Lock
@@ -34,6 +35,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.AccountConnectorsScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.SettingsAbout
@@ -87,6 +89,12 @@ fun SettingsScreen(
},
onClick = { NavigationController.navigateTo(SettingsConnection) },
),
SettingsRow(
icon = Icons.Filled.Extension,
label = stringResource(R.string.screen_account_connectors),
summary = stringResource(R.string.account_connectors_summary),
onClick = { NavigationController.navigateTo(AccountConnectorsScreen) },
),
SettingsRow(
icon = Icons.Filled.Palette,
label = stringResource(R.string.settings_sec_appearance),