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

@@ -80,6 +80,8 @@ import kotlinx.serialization.Serializable
@Serializable data object FilesScreen : NavKey
@Serializable data object AccountConnectorsScreen : NavKey
// ── Settings drill-down sub-pages ──────────────────────────────────────
@Serializable data object SettingsConnection : NavKey
@@ -39,6 +39,7 @@ import com.m57.hermescontrol.ui.bots.BotsScreen as BotsScreenContent
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
import com.m57.hermescontrol.ui.connectors.AccountConnectorsScreen as AccountConnectorsScreenContent
import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent
import com.m57.hermescontrol.ui.files.FilesScreen as FilesScreenContent
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
@@ -236,6 +237,12 @@ object ScreenRegistry {
Icons.Filled.EmojiEvents,
DrawerSection.INSPECT,
) { sessionId, openDrawer -> AchievementsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
AccountConnectorsScreen,
R.string.screen_account_connectors,
Icons.Filled.Extension,
DrawerSection.CONFIGURE,
) { _, openDrawer -> AccountConnectorsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
SettingsScreen,
R.string.screen_settings,
@@ -13,6 +13,7 @@ data class ConnectionOperationSnapshot(
val settled: Boolean,
val settledBy: String?,
val targets: List<ConnectionOperationTarget>,
val accountOwned: Boolean = false,
)
data class ConnectionOperationTarget(
@@ -231,3 +231,70 @@ sealed interface ConnectorConnectResult {
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
}
/** Account-level connector records exposed outside the chat session lifecycle. */
data class ConnectorAccount(
val connectionId: String,
val connector: String,
val status: String,
val statusReason: String?,
val label: String,
val alias: String?,
val active: Boolean,
val createdAt: String,
val updatedAt: String,
)
data class ConnectorCatalogEntry(
val slug: String,
val name: String,
val description: String,
val category: String,
)
data class ConnectorTool(
val slug: String,
val name: String,
val description: String,
val facet: String,
val deprecated: Boolean,
val hints: List<String> = emptyList(),
)
data class ConnectorPolicy(
val revision: String,
val mode: String,
val connectors: List<String>,
val disabledConnectors: List<String>,
val tools: Map<String, List<String>>,
val enabledTags: List<String> = emptyList(),
val disabledTags: List<String> = emptyList(),
val member: ConnectorPolicy? = null,
val inherited: List<ConnectorPolicy> = emptyList(),
) {
fun toolEnabled(
slug: String,
tool: ConnectorTool,
): Boolean =
connectorEnabled(slug) && tool.slug !in tools[slug].orEmpty() &&
disabledTags.none { it in tool.hints } && (enabledTags.isEmpty() || enabledTags.any { it in tool.hints })
fun connectorEnabled(slug: String): Boolean =
when (mode) {
"unrestricted" -> true
"deny-all" -> false
"allow" -> slug in connectors
"deny" -> slug !in disabledConnectors
else -> false
}
}
sealed interface AccountConnectorResult<out T> {
data class Success<T>(
val value: T,
) : AccountConnectorResult<T>
data class Failure(
val error: ConnectorError,
) : AccountConnectorResult<Nothing>
}
@@ -0,0 +1,341 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.local.AuthManager
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.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorPolicy
import com.m57.hermescontrol.data.model.ConnectorTool
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
interface AccountConnectorRepository {
suspend fun catalog(): AccountConnectorResult<List<ConnectorCatalogEntry>>
suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult
suspend fun connect(
slugs: List<String>,
reconnect: Boolean = false,
): AccountConnectorResult<com.m57.hermescontrol.data.model.ConnectionOperationSnapshot>
suspend fun accounts(): AccountConnectorResult<List<ConnectorAccount>>
suspend fun remove(connectionId: String): AccountConnectorResult<Unit>
suspend fun tools(
slug: String,
refresh: Boolean = false,
): AccountConnectorResult<List<ConnectorTool>>
suspend fun policy(): AccountConnectorResult<ConnectorPolicy>
suspend fun setConnectorEnabled(
slug: String,
enabled: Boolean,
expectedRevision: String,
): AccountConnectorResult<ConnectorPolicy>
suspend fun setDisabledTools(
slug: String,
disabled: List<String>,
expectedRevision: String,
): AccountConnectorResult<ConnectorPolicy>
suspend fun operationRequest(
method: String,
params: Map<String, Any>,
): Any?
suspend fun operationStatus(
opId: String,
): AccountConnectorResult<com.m57.hermescontrol.data.model.ConnectionOperationSnapshot>
companion object : AccountConnectorRepository by HermesAccountConnectorRepository()
}
class HermesAccountConnectorRepository(
private val rpc: suspend (String, Map<String, Any>) -> Any? = { method, params ->
val deferred = HermesWsClient.request(method, params)
try {
deferred.await()
} catch (
e: CancellationException,
) {
deferred.cancel(e)
throw e
} finally {
if (!deferred.isCompleted) deferred.cancel()
}
},
) : AccountConnectorRepository {
private suspend fun request(
method: String,
params: Map<String, Any> = emptyMap(),
) = rpc(method, params)
private suspend fun <T> guarded(block: suspend () -> T): AccountConnectorResult<T> =
try {
AccountConnectorResult.Success(block())
} catch (e: CancellationException) {
throw e
} catch (e: HermesWsClient.HermesRpcException) {
AccountConnectorResult.Failure(ConnectorParser.mapRpcError(e.code, e.message, e.data))
} catch (_: IllegalArgumentException) {
AccountConnectorResult.Failure(ConnectorError.InvalidResponse())
} catch (_: IllegalStateException) {
AccountConnectorResult.Failure(ConnectorError.InvalidResponse())
} catch (
_: Exception,
) {
AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway."))
}
private fun accountParams(params: Map<String, Any> = emptyMap()): Map<String, Any> =
buildMap {
putAll(params)
AuthManager.activeProfileId.value?.let { put("profile", it) }
}
private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject
private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty()
private fun JsonObject.bool(k: String) = this[k]?.jsonPrimitive?.contentOrNull == "true"
private fun JsonObject.array(k: String): JsonArray = this[k]?.jsonArray ?: JsonArray(emptyList())
private fun JsonObject.stringList(k: String) = array(k).mapNotNull { it.jsonPrimitive.contentOrNull }
override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult =
try {
ConnectorParser.parseListResult(
rpc(
WsMethods.CONNECTORS_LIST,
accountParams(
mapOf("owner" to mapOf("type" to "account")),
),
),
)
} catch (
e: CancellationException,
) {
throw e
} catch (
e: HermesWsClient.HermesRpcException,
) {
com.m57.hermescontrol.data.model.ConnectorListResult.Error(
ConnectorParser.mapRpcError(e.code, e.message, e.data),
)
} catch (
_: Exception,
) {
com.m57.hermescontrol.data.model.ConnectorListResult
.Error(ConnectorError.NetworkError())
}
override suspend fun connect(
slugs: List<String>,
reconnect: Boolean,
) = guarded {
require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug))
parseOperation(
request(
WsMethods.CONNECTORS_CONNECT,
accountParams(
mapOf(
"owner" to mapOf("type" to "account"),
"connectors" to slugs,
"reconnect" to reconnect,
),
),
),
)
}
override suspend fun operationRequest(
method: String,
params: Map<String, Any>,
): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account"))))
override suspend fun operationStatus(opId: String) =
guarded {
parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId)))
}
@Suppress("UNCHECKED_CAST")
private fun parseOperation(raw: Any?): com.m57.hermescontrol.data.model.ConnectionOperationSnapshot =
checkNotNull(
ConnectionOperationParser.parse(
(ConnectorParser.toJsonElement(raw).toAny() as Map<String, Any?>) +
("owner" to mapOf("type" to "account")),
),
)
override suspend fun catalog() =
guarded {
obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map {
val row = it.jsonObject
ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category"))
}
}
override suspend fun accounts() =
guarded {
obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e ->
val r = e.jsonObject
ConnectorAccount(
r.str("connection_id"),
r.str("connector"),
r.str("status"),
r["status_reason"]?.jsonPrimitive?.contentOrNull,
r.str("label"),
r["alias"]?.jsonPrimitive?.contentOrNull,
r.bool("active"),
r.str("created_at"),
r.str("updated_at"),
)
}
}
override suspend fun remove(connectionId: String) =
guarded {
require(connectionId.isNotBlank())
val response =
obj(
request(
WsMethods.CONNECTORS_ACCOUNTS_REMOVE,
accountParams(
mapOf("connection_id" to connectionId),
),
),
)
check(response.str("status") == "removed" && response.str("connection_id") == connectionId)
Unit
}
override suspend fun tools(
slug: String,
refresh: Boolean,
) = guarded {
require(ConnectorRepository.isValidSlug(slug))
val params =
buildMap<String, Any> {
put("slug", slug)
if (refresh) put("refresh", true)
}
obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e ->
val r = e.jsonObject
ConnectorTool(
r.str("slug"),
r.str("name"),
r.str("description"),
r.str("facet"),
r.bool("deprecated"),
r.stringList("hints"),
)
}
}
override suspend fun policy() =
guarded {
val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams()))
val layers = root.array("layers").map { it.jsonObject }
val member = layers.firstOrNull { it.str("kind") == "member" }
parsePolicy(root["effective"]!!.jsonObject).copy(
member = member?.let { parsePolicy(it["body"]!!.jsonObject, it.str("revision")) },
inherited =
layers.filter { it.str("kind") != "member" }.map {
parsePolicy(it["body"]!!.jsonObject, it.str("revision"))
},
)
}
override suspend fun setConnectorEnabled(
slug: String,
enabled: Boolean,
expectedRevision: String,
) = setPolicy(
expectedRevision,
mapOf(
"type" to "connector",
"connector" to slug,
"enabled" to enabled,
),
)
override suspend fun setDisabledTools(
slug: String,
disabled: List<String>,
expectedRevision: String,
) = setPolicy(
expectedRevision,
mapOf(
"type" to "tools",
"connector" to slug,
"disabled_tools" to disabled,
),
)
private suspend fun setPolicy(
expectedRevision: String,
change: Map<String, Any>,
) = guarded {
val scope = AuthManager.currentDataScope()
require(ConnectorRepository.isValidSlug(change["connector"] as? String))
require(expectedRevision.isNotBlank())
check(scope == AuthManager.currentDataScope()) { "Account scope changed" }
val result =
obj(
request(
WsMethods.CONNECTORS_POLICY_SET,
accountParams(
mapOf(
"change" to change,
"expected_revision" to expectedRevision,
),
),
),
)
check(scope == AuthManager.currentDataScope()) { "Account scope changed" }
when (val refreshed = policy()) {
is AccountConnectorResult.Success -> refreshed.value
is AccountConnectorResult.Failure -> parsePolicy(result["effective"]!!.jsonObject)
}
}
private fun parsePolicy(
r: JsonObject,
revision: String = r.str("revision"),
) = ConnectorPolicy(
revision,
r.str("mode"),
r.stringList("connectors"),
r.stringList("disabled_connectors"),
(r["tools"] as? JsonObject)?.mapValues { (_, v) ->
(v as? JsonArray)?.mapNotNull { it.jsonPrimitive.contentOrNull }
?: emptyList()
}
?: emptyMap(),
enabledTags =
(r["tags"] as? JsonObject)
?.let {
(it["enable"] as? JsonArray)?.mapNotNull { tag ->
tag.jsonPrimitive.contentOrNull
}
}.orEmpty(),
disabledTags =
(r["tags"] as? JsonObject)
?.let {
(it["disable"] as? JsonArray)?.mapNotNull { tag ->
tag.jsonPrimitive.contentOrNull
}
}.orEmpty(),
)
}
@@ -39,6 +39,7 @@ object ConnectionOperationParser {
settled = payload["settled"] as? Boolean ?: false,
settledBy = payload["settled_by"] as? String,
targets = targets,
accountOwned = (payload["owner"] as? Map<*, *>)?.get("type") == "account",
)
}
@@ -104,12 +104,19 @@ object WsMethods {
const val CONNECTION_RESPOND = "connection.respond"
const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake"
const val CONNECTORS_OPERATION_STATUS = "connectors.operation.status"
/** List available third-party connectors and authorization state for a session. */
const val CONNECTORS_LIST = "connectors.list"
/** Initiate or restart authorization flow for one or more connectors. */
const val CONNECTORS_CONNECT = "connectors.connect"
const val CONNECTORS_CATALOG = "connectors.catalog"
const val CONNECTORS_ACCOUNTS = "connectors.accounts"
const val CONNECTORS_ACCOUNTS_REMOVE = "connectors.accounts.remove"
const val CONNECTORS_TOOLS = "connectors.tools"
const val CONNECTORS_POLICY_GET = "connectors.policy.get"
const val CONNECTORS_POLICY_SET = "connectors.policy.set"
// ── Plugins ───────────────────────────────────────────────────────────
const val PLUGINS_MANAGE = "plugins.manage"
@@ -165,6 +172,12 @@ object WsMethods {
SESSION_CREATE, // session.create
SESSION_LIST, // session.list
MODEL_OPTIONS, // model.options: @_profile_scoped
CONNECTORS_CATALOG,
CONNECTORS_ACCOUNTS,
CONNECTORS_ACCOUNTS_REMOVE,
CONNECTORS_TOOLS,
CONNECTORS_POLICY_GET,
CONNECTORS_POLICY_SET,
SESSION_RESUME, // session.resume
SESSION_DELETE, // session.delete
SESSION_STATUS, // session.status
@@ -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),