This commit is contained in:
@@ -80,6 +80,8 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object FilesScreen : NavKey
|
@Serializable data object FilesScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data object AccountConnectorsScreen : NavKey
|
||||||
|
|
||||||
// ── Settings drill-down sub-pages ──────────────────────────────────────
|
// ── Settings drill-down sub-pages ──────────────────────────────────────
|
||||||
|
|
||||||
@Serializable data object SettingsConnection : NavKey
|
@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.channels.ChannelsScreen as ChannelsScreenContent
|
||||||
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
|
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
|
||||||
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
|
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.cron.CronJobsScreen as CronJobsScreenContent
|
||||||
import com.m57.hermescontrol.ui.files.FilesScreen as FilesScreenContent
|
import com.m57.hermescontrol.ui.files.FilesScreen as FilesScreenContent
|
||||||
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
|
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
|
||||||
@@ -236,6 +237,12 @@ object ScreenRegistry {
|
|||||||
Icons.Filled.EmojiEvents,
|
Icons.Filled.EmojiEvents,
|
||||||
DrawerSection.INSPECT,
|
DrawerSection.INSPECT,
|
||||||
) { sessionId, openDrawer -> AchievementsScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> AchievementsScreenContent(onOpenDrawer = openDrawer) },
|
||||||
|
ScreenDefinition(
|
||||||
|
AccountConnectorsScreen,
|
||||||
|
R.string.screen_account_connectors,
|
||||||
|
Icons.Filled.Extension,
|
||||||
|
DrawerSection.CONFIGURE,
|
||||||
|
) { _, openDrawer -> AccountConnectorsScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
SettingsScreen,
|
SettingsScreen,
|
||||||
R.string.screen_settings,
|
R.string.screen_settings,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ data class ConnectionOperationSnapshot(
|
|||||||
val settled: Boolean,
|
val settled: Boolean,
|
||||||
val settledBy: String?,
|
val settledBy: String?,
|
||||||
val targets: List<ConnectionOperationTarget>,
|
val targets: List<ConnectionOperationTarget>,
|
||||||
|
val accountOwned: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ConnectionOperationTarget(
|
data class ConnectionOperationTarget(
|
||||||
|
|||||||
@@ -231,3 +231,70 @@ sealed interface ConnectorConnectResult {
|
|||||||
|
|
||||||
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
|
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,
|
settled = payload["settled"] as? Boolean ?: false,
|
||||||
settledBy = payload["settled_by"] as? String,
|
settledBy = payload["settled_by"] as? String,
|
||||||
targets = targets,
|
targets = targets,
|
||||||
|
accountOwned = (payload["owner"] as? Map<*, *>)?.get("type") == "account",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,12 +104,19 @@ object WsMethods {
|
|||||||
|
|
||||||
const val CONNECTION_RESPOND = "connection.respond"
|
const val CONNECTION_RESPOND = "connection.respond"
|
||||||
const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake"
|
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. */
|
/** List available third-party connectors and authorization state for a session. */
|
||||||
const val CONNECTORS_LIST = "connectors.list"
|
const val CONNECTORS_LIST = "connectors.list"
|
||||||
|
|
||||||
/** Initiate or restart authorization flow for one or more connectors. */
|
/** Initiate or restart authorization flow for one or more connectors. */
|
||||||
const val CONNECTORS_CONNECT = "connectors.connect"
|
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 ───────────────────────────────────────────────────────────
|
// ── Plugins ───────────────────────────────────────────────────────────
|
||||||
const val PLUGINS_MANAGE = "plugins.manage"
|
const val PLUGINS_MANAGE = "plugins.manage"
|
||||||
@@ -165,6 +172,12 @@ object WsMethods {
|
|||||||
SESSION_CREATE, // session.create
|
SESSION_CREATE, // session.create
|
||||||
SESSION_LIST, // session.list
|
SESSION_LIST, // session.list
|
||||||
MODEL_OPTIONS, // model.options: @_profile_scoped
|
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_RESUME, // session.resume
|
||||||
SESSION_DELETE, // session.delete
|
SESSION_DELETE, // session.delete
|
||||||
SESSION_STATUS, // session.status
|
SESSION_STATUS, // session.status
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ internal class ConnectionResumeCheckpoint(
|
|||||||
/** Session-scoped authoritative reducer for backend-owned connector operations. */
|
/** Session-scoped authoritative reducer for backend-owned connector operations. */
|
||||||
class ChatConnectionOperationDelegate(
|
class ChatConnectionOperationDelegate(
|
||||||
private val requester: ConnectionOperationRequester,
|
private val requester: ConnectionOperationRequester,
|
||||||
|
private val accountOwned: Boolean,
|
||||||
) {
|
) {
|
||||||
|
constructor(requester: ConnectionOperationRequester) : this(requester, false)
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ConnectionOperationUiState())
|
private val _state = MutableStateFlow(ConnectionOperationUiState())
|
||||||
val state: StateFlow<ConnectionOperationUiState> = _state.asStateFlow()
|
val state: StateFlow<ConnectionOperationUiState> = _state.asStateFlow()
|
||||||
private var sessionId: String? = null
|
private var sessionId: String? = null
|
||||||
@@ -170,7 +173,7 @@ class ChatConnectionOperationDelegate(
|
|||||||
method = WsMethods.CONNECTION_RESPOND,
|
method = WsMethods.CONNECTION_RESPOND,
|
||||||
params =
|
params =
|
||||||
mapOf(
|
mapOf(
|
||||||
"owner" to ConnectorRepository.sessionOwner(requireSessionId()),
|
"owner" to ownerParams(),
|
||||||
"op_id" to snapshot.opId,
|
"op_id" to snapshot.opId,
|
||||||
"result" to mapOf("targets" to listOf(answer)),
|
"result" to mapOf("targets" to listOf(answer)),
|
||||||
),
|
),
|
||||||
@@ -183,7 +186,7 @@ class ChatConnectionOperationDelegate(
|
|||||||
method = WsMethods.CONNECTION_RESPOND,
|
method = WsMethods.CONNECTION_RESPOND,
|
||||||
params =
|
params =
|
||||||
mapOf(
|
mapOf(
|
||||||
"owner" to ConnectorRepository.sessionOwner(requireSessionId()),
|
"owner" to ownerParams(),
|
||||||
"op_id" to snapshot.opId,
|
"op_id" to snapshot.opId,
|
||||||
"result" to mapOf("settled_by" to "continue"),
|
"result" to mapOf("settled_by" to "continue"),
|
||||||
),
|
),
|
||||||
@@ -196,7 +199,7 @@ class ChatConnectionOperationDelegate(
|
|||||||
val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return
|
val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return
|
||||||
dispatch(
|
dispatch(
|
||||||
method = WsMethods.CONNECTORS_OPERATION_WAKE,
|
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,
|
clearOnSuccess = true,
|
||||||
unknownOperationSettles = true,
|
unknownOperationSettles = true,
|
||||||
)
|
)
|
||||||
@@ -237,7 +240,7 @@ class ChatConnectionOperationDelegate(
|
|||||||
@Synchronized
|
@Synchronized
|
||||||
private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? {
|
private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? {
|
||||||
val current = _state.value.operation ?: return null
|
val current = _state.value.operation ?: return null
|
||||||
if (sessionId.isNullOrBlank() ||
|
if ((!accountOwned && sessionId.isNullOrBlank()) ||
|
||||||
_state.value.pendingAction != null ||
|
_state.value.pendingAction != null ||
|
||||||
current.opId != action.opId ||
|
current.opId != action.opId ||
|
||||||
current.seq != action.observedSeq
|
current.seq != action.observedSeq
|
||||||
@@ -259,9 +262,15 @@ class ChatConnectionOperationDelegate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean =
|
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 =
|
private fun currentOp(): String =
|
||||||
_state.value.operation
|
_state.value.operation
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ internal fun ToolBubble(
|
|||||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
// ── Header row: icon + tool name ──
|
// ── Header row: icon + tool name ──
|
||||||
HeaderRow(message, config, contentColor, statusColors)
|
HeaderRow(message, config, contentColor, statusColors, view?.serverDisplayName)
|
||||||
|
|
||||||
// ── Tool progress preview (tool.progress) ──
|
// ── Tool progress preview (tool.progress) ──
|
||||||
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
|
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
|
||||||
@@ -528,6 +528,7 @@ private fun HeaderRow(
|
|||||||
config: ToolDisplayConfig,
|
config: ToolDisplayConfig,
|
||||||
contentColor: Color,
|
contentColor: Color,
|
||||||
statusColors: HermesStatusColors,
|
statusColors: HermesStatusColors,
|
||||||
|
displayName: String? = null,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -562,7 +563,7 @@ private fun HeaderRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = message.toolName ?: stringResource(R.string.chat_tool_fallback),
|
text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.labelMedium.copy(
|
MaterialTheme.typography.labelMedium.copy(
|
||||||
color = contentColor,
|
color = contentColor,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat
|
|||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolView
|
import com.m57.hermescontrol.ui.chat.tool.ToolView
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder
|
import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
@@ -42,7 +43,15 @@ fun parseToolOutput(
|
|||||||
// Extract args sub-object if present (new tool.complete format) and
|
// Extract args sub-object if present (new tool.complete format) and
|
||||||
// the result sub-object, falling back to the top-level payload (old
|
// the result sub-object, falling back to the top-level payload (old
|
||||||
// format where result fields sat at the root).
|
// 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
|
val result = element["result"] as? JsonObject ?: element
|
||||||
|
|
||||||
ToolViewBuilder.build(
|
ToolViewBuilder.build(
|
||||||
|
|||||||
@@ -64,4 +64,5 @@ data class ToolView(
|
|||||||
val searchQuery: String? = null,
|
val searchQuery: String? = null,
|
||||||
/** Detected error text (destructive styling in the view). */
|
/** Detected error text (destructive styling in the view). */
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
val serverDisplayName: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.tool
|
package com.m57.hermescontrol.ui.chat.tool
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
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
|
* The tool-display engine's orchestrator — a port of the desktop app's
|
||||||
@@ -48,8 +51,10 @@ object ToolViewBuilder {
|
|||||||
ToolStatusResolver.errorText(call, isError)
|
ToolStatusResolver.errorText(call, isError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val serverTitle = serverLabelTitle(call.args)
|
||||||
val title =
|
val title =
|
||||||
when {
|
serverTitle
|
||||||
|
?: when {
|
||||||
running -> renderer.pendingTitle(call)
|
running -> renderer.pendingTitle(call)
|
||||||
error.isNotEmpty() -> renderer.errorTitle(call)
|
error.isNotEmpty() -> renderer.errorTitle(call)
|
||||||
else -> renderer.doneTitle(call)
|
else -> renderer.doneTitle(call)
|
||||||
@@ -103,6 +108,7 @@ object ToolViewBuilder {
|
|||||||
return ToolView(
|
return ToolView(
|
||||||
status = status,
|
status = status,
|
||||||
title = title,
|
title = title,
|
||||||
|
serverDisplayName = serverTitle,
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
detail = clampedDetail,
|
detail = clampedDetail,
|
||||||
detailLabel = extras.detailLabel,
|
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. */
|
/** Subtitle fallback: first line of the heuristic summary, then contexts. */
|
||||||
private fun genericSubtitle(call: ToolCall): String {
|
private fun genericSubtitle(call: ToolCall): String {
|
||||||
val summary = ToolResultSummary.formatToolResultSummary(call.rawResult)
|
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.ArrowForward
|
||||||
import androidx.compose.material.icons.automirrored.filled.ListAlt
|
import androidx.compose.material.icons.automirrored.filled.ListAlt
|
||||||
import androidx.compose.material.icons.filled.ChatBubbleOutline
|
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.Info
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Lock
|
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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.AccountConnectorsScreen
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.SettingsAbout
|
import com.m57.hermescontrol.SettingsAbout
|
||||||
@@ -87,6 +89,12 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
onClick = { NavigationController.navigateTo(SettingsConnection) },
|
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(
|
SettingsRow(
|
||||||
icon = Icons.Filled.Palette,
|
icon = Icons.Filled.Palette,
|
||||||
label = stringResource(R.string.settings_sec_appearance),
|
label = stringResource(R.string.settings_sec_appearance),
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
<string name="screen_cron">Cron</string>
|
<string name="screen_cron">Cron</string>
|
||||||
<string name="screen_system">System</string>
|
<string name="screen_system">System</string>
|
||||||
<string name="screen_settings">Settings</string>
|
<string name="screen_settings">Settings</string>
|
||||||
|
<string name="screen_account_connectors">Connectors</string>
|
||||||
<string name="screen_profiles">Profiles</string>
|
<string name="screen_profiles">Profiles</string>
|
||||||
<string name="screen_bots">Bots</string>
|
<string name="screen_bots">Bots</string>
|
||||||
<string name="bots_active_now">Active Now</string>
|
<string name="bots_active_now">Active Now</string>
|
||||||
@@ -248,6 +249,17 @@
|
|||||||
|
|
||||||
<!-- Settings Screen -->
|
<!-- Settings Screen -->
|
||||||
<string name="settings_sec_connection">Connection</string>
|
<string name="settings_sec_connection">Connection</string>
|
||||||
|
<string name="account_connectors_title">Account connectors</string>
|
||||||
|
<string name="account_connectors_summary">Manage account authorizations and tool access without a chat session.</string>
|
||||||
|
<string name="account_connectors_accounts">Authorized accounts</string>
|
||||||
|
<string name="account_connectors_connect">Connect</string>
|
||||||
|
<string name="account_connectors_tools">Tools</string>
|
||||||
|
<string name="account_connectors_enabled">Enabled</string>
|
||||||
|
<string name="account_connectors_not_connected">Not connected</string>
|
||||||
|
<string name="account_connectors_no_accounts">No authorized connector accounts.</string>
|
||||||
|
<string name="account_connectors_remove">Remove</string>
|
||||||
|
<string name="account_connectors_remove_confirm_title">Remove connector account?</string>
|
||||||
|
<string name="account_connectors_remove_confirm_message">Remove %1$s? You may need to authorize it again.</string>
|
||||||
<string name="settings_field_token">Auth Token</string>
|
<string name="settings_field_token">Auth Token</string>
|
||||||
<string name="settings_keep_connected_in_background_title">Stay connected in background</string>
|
<string name="settings_keep_connected_in_background_title">Stay connected in background</string>
|
||||||
<string name="settings_keep_connected_in_background_desc">Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery.</string>
|
<string name="settings_keep_connected_in_background_desc">Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery.</string>
|
||||||
@@ -1804,4 +1816,7 @@
|
|||||||
<string name="plugins_update_consent_missing">The server did not provide capability details.</string>
|
<string name="plugins_update_consent_missing">The server did not provide capability details.</string>
|
||||||
<string name="plugins_update_accept">Accept and update</string>
|
<string name="plugins_update_accept">Accept and update</string>
|
||||||
<string name="sessions_storage_corrupt">Session storage is damaged for %1$s. History may be incomplete; previously loaded sessions are kept. Restore or recover the database on the server, then refresh.</string>
|
<string name="sessions_storage_corrupt">Session storage is damaged for %1$s. History may be incomplete; previously loaded sessions are kept. Restore or recover the database on the server, then refresh.</string>
|
||||||
|
<string name="account_connectors_reconnect">Reconnect</string>
|
||||||
|
<string name="account_connectors_no_tools">No tools available</string>
|
||||||
|
<string name="account_connectors_policy_locked">Read-only policy restrictions</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
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.ConnectorTool
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AccountConnectorRepositoryTest {
|
||||||
|
@Before fun setup() {
|
||||||
|
mockkObject(AuthManager)
|
||||||
|
every { AuthManager.activeProfileId } returns MutableStateFlow("work")
|
||||||
|
every { AuthManager.currentDataScope() } returns null
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun teardown() = unmockkAll()
|
||||||
|
|
||||||
|
@Test fun `connect decodes operation snapshot and preserves authorization link without a session`() =
|
||||||
|
runTest {
|
||||||
|
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||||
|
val repo =
|
||||||
|
HermesAccountConnectorRepository { method, params ->
|
||||||
|
calls += method to params
|
||||||
|
operationFixture()
|
||||||
|
}
|
||||||
|
val result = repo.connect(listOf("drive"), true) as AccountConnectorResult.Success
|
||||||
|
assertTrue(result.value.accountOwned)
|
||||||
|
assertEquals("op-a", result.value.opId)
|
||||||
|
assertEquals(
|
||||||
|
"https://example.com/authorize",
|
||||||
|
result.value.targets
|
||||||
|
.single()
|
||||||
|
.safeConnectUrl,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
mapOf(
|
||||||
|
"profile" to "work",
|
||||||
|
"owner" to mapOf("type" to "account"),
|
||||||
|
"connectors" to listOf("drive"),
|
||||||
|
"reconnect" to true,
|
||||||
|
),
|
||||||
|
calls.single().second,
|
||||||
|
)
|
||||||
|
assertEquals(WsMethods.CONNECTORS_CONNECT, calls.single().first)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `account operation respond status and wake retain profile and owner`() =
|
||||||
|
runTest {
|
||||||
|
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||||
|
val repo =
|
||||||
|
HermesAccountConnectorRepository { method, params ->
|
||||||
|
calls += method to params
|
||||||
|
operationFixture()
|
||||||
|
}
|
||||||
|
repo.operationStatus("op-a")
|
||||||
|
repo.operationRequest(
|
||||||
|
WsMethods.CONNECTION_RESPOND,
|
||||||
|
mapOf(
|
||||||
|
"op_id" to "op-a",
|
||||||
|
"result" to mapOf("settled_by" to "continue"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a"))
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
WsMethods.CONNECTORS_OPERATION_STATUS,
|
||||||
|
WsMethods.CONNECTION_RESPOND,
|
||||||
|
WsMethods.CONNECTORS_OPERATION_WAKE,
|
||||||
|
),
|
||||||
|
calls.map {
|
||||||
|
it.first
|
||||||
|
},
|
||||||
|
)
|
||||||
|
calls.forEach { (_, params) ->
|
||||||
|
assertEquals("work", params["profile"])
|
||||||
|
assertEquals(mapOf("type" to "account"), params["owner"])
|
||||||
|
assertEquals("op-a", params["op_id"])
|
||||||
|
assertFalse("session_id" in params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `catalog accounts tools and removal use native account endpoints`() =
|
||||||
|
runTest {
|
||||||
|
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||||
|
val repo =
|
||||||
|
HermesAccountConnectorRepository { method, params ->
|
||||||
|
calls += method to params
|
||||||
|
when (method) {
|
||||||
|
WsMethods.CONNECTORS_CATALOG -> {
|
||||||
|
mapOf(
|
||||||
|
"connectors" to
|
||||||
|
listOf(
|
||||||
|
mapOf(
|
||||||
|
"slug" to "drive",
|
||||||
|
"name" to "Google Drive",
|
||||||
|
"description" to "Files",
|
||||||
|
"category" to "storage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
WsMethods.CONNECTORS_ACCOUNTS -> {
|
||||||
|
mapOf(
|
||||||
|
"accounts" to
|
||||||
|
listOf(
|
||||||
|
mapOf(
|
||||||
|
"connection_id" to "conn-1",
|
||||||
|
"connector" to "drive",
|
||||||
|
"label" to "Work account",
|
||||||
|
"status" to "active",
|
||||||
|
"active" to true,
|
||||||
|
"alias" to null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
WsMethods.CONNECTORS_TOOLS -> {
|
||||||
|
mapOf(
|
||||||
|
"tools" to
|
||||||
|
listOf(
|
||||||
|
mapOf(
|
||||||
|
"slug" to "delete",
|
||||||
|
"name" to "Delete file",
|
||||||
|
"hints" to listOf("destructive"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
WsMethods.CONNECTORS_ACCOUNTS_REMOVE -> {
|
||||||
|
mapOf(
|
||||||
|
"connection_id" to "conn-1",
|
||||||
|
"connector" to "drive",
|
||||||
|
"status" to "removed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
error(method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("Google Drive", (repo.catalog() as AccountConnectorResult.Success).value.single().name)
|
||||||
|
assertEquals("Work account", (repo.accounts() as AccountConnectorResult.Success).value.single().label)
|
||||||
|
assertEquals(
|
||||||
|
"Delete file",
|
||||||
|
(repo.tools("drive", true) as AccountConnectorResult.Success).value.single().name,
|
||||||
|
)
|
||||||
|
assertTrue(repo.remove("conn-1") is AccountConnectorResult.Success)
|
||||||
|
assertTrue(calls.all { "owner" !in it.second && it.second["profile"] == "work" })
|
||||||
|
assertEquals(mapOf("profile" to "work", "slug" to "drive", "refresh" to true), calls[2].second)
|
||||||
|
assertEquals(mapOf("profile" to "work", "connection_id" to "conn-1"), calls[3].second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `policy keeps member revision separate from effective and respects inherited rules`() =
|
||||||
|
runTest {
|
||||||
|
val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() }
|
||||||
|
val policy = (repo.policy() as AccountConnectorResult.Success).value
|
||||||
|
assertEquals("effective-revision", policy.revision)
|
||||||
|
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision)
|
||||||
|
assertEquals(listOf("mine"), policy.member?.tools?.get("drive"))
|
||||||
|
val destructive = ConnectorTool("delete", "Delete", "", "destructive", false, listOf("destructive"))
|
||||||
|
assertFalse(policy.inherited.single().toolEnabled("drive", destructive))
|
||||||
|
assertFalse(policy.copy(mode = "deny-all").connectorEnabled("drive"))
|
||||||
|
assertFalse(policy.copy(mode = "future-mode").connectorEnabled("drive"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `writes use observed member revision not effective revision or fresh overwritten version`() =
|
||||||
|
runTest {
|
||||||
|
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||||
|
val repo =
|
||||||
|
HermesAccountConnectorRepository { method, params ->
|
||||||
|
calls += method to params
|
||||||
|
if (method == WsMethods.CONNECTORS_POLICY_GET) {
|
||||||
|
policyFixture()
|
||||||
|
} else {
|
||||||
|
mapOf(
|
||||||
|
"revision" to "new-member-revision",
|
||||||
|
"effective" to mapOf("mode" to "deny-all", "revision" to "effective-revision"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
repo.setDisabledTools(
|
||||||
|
"drive",
|
||||||
|
listOf("delete"),
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
) is AccountConnectorResult.Success,
|
||||||
|
)
|
||||||
|
assertEquals(WsMethods.CONNECTORS_POLICY_SET, calls.first().first)
|
||||||
|
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", calls.first().second["expected_revision"])
|
||||||
|
assertEquals(
|
||||||
|
mapOf("type" to "tools", "connector" to "drive", "disabled_tools" to listOf("delete")),
|
||||||
|
calls.first().second["change"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `malformed authorization does not look like a successful connect`() =
|
||||||
|
runTest {
|
||||||
|
val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") }
|
||||||
|
assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun operationFixture() =
|
||||||
|
mapOf(
|
||||||
|
"op_id" to "op-a",
|
||||||
|
"seq" to 1,
|
||||||
|
"deadline_at" to 2000000000.0,
|
||||||
|
"settled" to false,
|
||||||
|
"targets" to
|
||||||
|
listOf(
|
||||||
|
mapOf(
|
||||||
|
"name" to "drive",
|
||||||
|
"kind" to "connector",
|
||||||
|
"action" to "authorize",
|
||||||
|
"state" to "initiated",
|
||||||
|
"connect_url" to "https://example.com/authorize",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun policyFixture() =
|
||||||
|
mapOf(
|
||||||
|
"effective" to
|
||||||
|
mapOf(
|
||||||
|
"mode" to "deny",
|
||||||
|
"revision" to "effective-revision",
|
||||||
|
"disabled_connectors" to emptyList<String>(),
|
||||||
|
),
|
||||||
|
"layers" to
|
||||||
|
listOf(
|
||||||
|
mapOf(
|
||||||
|
"kind" to "member",
|
||||||
|
"revision" to "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
"body" to mapOf("mode" to "deny", "tools" to mapOf("drive" to listOf("mine"))),
|
||||||
|
),
|
||||||
|
mapOf(
|
||||||
|
"kind" to "org",
|
||||||
|
"revision" to "org-revision",
|
||||||
|
"body" to mapOf("mode" to "deny", "tags" to mapOf("disable" to listOf("destructive"))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.tool
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ConnectorToolLabelsTest {
|
||||||
|
@Test fun `gateway envelope labels reach the visible header without replacing real tool arguments`() {
|
||||||
|
val content = """{
|
||||||
|
"name":"tool_call",
|
||||||
|
"args":{"labels":["bug"]},
|
||||||
|
"labels":[{"app":"GitHub","text":"Create issue"}],
|
||||||
|
"result":{}
|
||||||
|
}"""
|
||||||
|
val view =
|
||||||
|
com.m57.hermescontrol.ui.chat
|
||||||
|
.parseToolOutput(content, "tool_call", false)
|
||||||
|
assertEquals("GitHub · Create issue", view?.serverDisplayName)
|
||||||
|
assertEquals("GitHub · Create issue", view?.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `server connector labels title both live and restored tool calls`() {
|
||||||
|
val args =
|
||||||
|
Json.parseToJsonElement(
|
||||||
|
"""{"hermes_tool_labels":[{"app":"Google Drive","text":"List files","kind":"connector"}]}""",
|
||||||
|
)
|
||||||
|
val running = ToolViewBuilder.build("tool_call", args, null, running = true)
|
||||||
|
val restored = ToolViewBuilder.build("tool_call", args, Json.parseToJsonElement("{}"))
|
||||||
|
assertEquals("Google Drive · List files", running.title)
|
||||||
|
assertEquals(running.title, restored.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `malformed metadata and actual labels argument keep fallback title`() {
|
||||||
|
val args = Json.parseToJsonElement("""{"labels":["bug"],"hermes_tool_labels":[null,42,{"app":"GitHub"}]}""")
|
||||||
|
val view = ToolViewBuilder.build("tool_call", args, null)
|
||||||
|
assertTrue(view.title.isNotBlank())
|
||||||
|
assertEquals(ToolViewBuilder.build("tool_call", null, null).title, view.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
package com.m57.hermescontrol.ui.connectors
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
|
import com.m57.hermescontrol.data.model.AccountConnectorResult
|
||||||
|
import com.m57.hermescontrol.data.model.ConnectorCatalogEntry
|
||||||
|
import com.m57.hermescontrol.data.model.ConnectorError
|
||||||
|
import com.m57.hermescontrol.data.model.ConnectorListResult
|
||||||
|
import com.m57.hermescontrol.data.model.ConnectorPolicy
|
||||||
|
import com.m57.hermescontrol.data.ws.AccountConnectorRepository
|
||||||
|
import com.m57.hermescontrol.data.ws.ConnectionOperationParser
|
||||||
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AccountConnectorsViewModelTest {
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val repo = mockk<AccountConnectorRepository>()
|
||||||
|
private val scopes = MutableStateFlow<DataScope?>(DataScope("server", "http://host", "work", 1))
|
||||||
|
private val events = MutableSharedFlow<WsEvent>()
|
||||||
|
private lateinit var vm: AccountConnectorsViewModel
|
||||||
|
private val policy = ConnectorPolicy("effective", "unrestricted", emptyList(), emptyList(), emptyMap())
|
||||||
|
|
||||||
|
@Before fun setup() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
coEvery { repo.listConnectors() } returns ConnectorListResult.Success(true, emptyList())
|
||||||
|
coEvery { repo.catalog() } returns
|
||||||
|
AccountConnectorResult.Success(listOf(ConnectorCatalogEntry("drive", "Google Drive", "Files", "storage")))
|
||||||
|
coEvery { repo.accounts() } returns AccountConnectorResult.Success(emptyList())
|
||||||
|
coEvery { repo.policy() } returns
|
||||||
|
AccountConnectorResult.Success(policy.copy(member = policy.copy(revision = "member")))
|
||||||
|
coEvery { repo.operationStatus("op-a") } returns AccountConnectorResult.Success(snapshot())
|
||||||
|
vm = AccountConnectorsViewModel(repo, scopes, { scopes.value }, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun teardown() {
|
||||||
|
vm.viewModelScope.cancel()
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `no session required and connect retains the browser operation`() =
|
||||||
|
runTest(dispatcher) {
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertFalse(vm.state.value.loading)
|
||||||
|
assertEquals(
|
||||||
|
"Google Drive",
|
||||||
|
vm.state.value.catalog
|
||||||
|
.single()
|
||||||
|
.name,
|
||||||
|
)
|
||||||
|
coEvery { repo.connect(listOf("drive"), false) } returns AccountConnectorResult.Success(snapshot())
|
||||||
|
vm.connect("drive")
|
||||||
|
vm.connect("drive")
|
||||||
|
advanceUntilIdle()
|
||||||
|
coVerify(exactly = 1) { repo.connect(listOf("drive"), false) }
|
||||||
|
assertEquals(
|
||||||
|
"https://example.com/auth",
|
||||||
|
vm.connectionState.value.operation
|
||||||
|
?.targets
|
||||||
|
?.single()
|
||||||
|
?.safeConnectUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `connect and remove errors remain visible rather than cleared by reload`() =
|
||||||
|
runTest(dispatcher) {
|
||||||
|
advanceUntilIdle()
|
||||||
|
coEvery { repo.connect(any(), any()) } returns
|
||||||
|
AccountConnectorResult.Failure(ConnectorError.RequestFailed("authorization failed"))
|
||||||
|
vm.connect("drive")
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals("authorization failed", vm.state.value.error)
|
||||||
|
coEvery { repo.remove("c1") } returns
|
||||||
|
AccountConnectorResult.Failure(ConnectorError.RequestFailed("remove failed"))
|
||||||
|
vm.remove("c1")
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals("remove failed", vm.state.value.error)
|
||||||
|
assertFalse(vm.state.value.busy)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `profile switch clears operation and rejects prior account update`() =
|
||||||
|
runTest(dispatcher) {
|
||||||
|
advanceUntilIdle()
|
||||||
|
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
|
||||||
|
vm.connect("drive")
|
||||||
|
advanceUntilIdle()
|
||||||
|
scopes.value = scopes.value?.copy(activeProfileId = "other")
|
||||||
|
advanceUntilIdle()
|
||||||
|
events.emit(WsEvent.ConnectionUpdate(snapshot().copy(seq = 2)))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertNull(vm.connectionState.value.operation)
|
||||||
|
assertEquals(
|
||||||
|
"other",
|
||||||
|
vm.state.value.scope
|
||||||
|
?.activeProfileId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `late tool response cannot populate a new account scope`() =
|
||||||
|
runTest(dispatcher) {
|
||||||
|
advanceUntilIdle()
|
||||||
|
val pending =
|
||||||
|
CompletableDeferred<AccountConnectorResult<List<com.m57.hermescontrol.data.model.ConnectorTool>>>()
|
||||||
|
coEvery { repo.tools("drive", false) } coAnswers { pending.await() }
|
||||||
|
vm.loadTools("drive")
|
||||||
|
dispatcher.scheduler.runCurrent()
|
||||||
|
scopes.value = scopes.value?.copy(inMemoryAuthGeneration = 2)
|
||||||
|
advanceUntilIdle()
|
||||||
|
pending.complete(AccountConnectorResult.Success(emptyList()))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(
|
||||||
|
vm.state.value.tools
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.state.value.toolsLoading
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `continue answers account operation and settlement closes it`() =
|
||||||
|
runTest(dispatcher) {
|
||||||
|
advanceUntilIdle()
|
||||||
|
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
|
||||||
|
coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok")
|
||||||
|
coEvery { repo.operationStatus("op-a") } returns
|
||||||
|
AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true))
|
||||||
|
vm.connect("drive")
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.continueOperation()
|
||||||
|
advanceUntilIdle()
|
||||||
|
coVerify {
|
||||||
|
repo.operationRequest(
|
||||||
|
WsMethods.CONNECTION_RESPOND,
|
||||||
|
mapOf(
|
||||||
|
"owner" to mapOf("type" to "account"),
|
||||||
|
"op_id" to "op-a",
|
||||||
|
"result" to mapOf("settled_by" to "continue"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertNull(vm.connectionState.value.operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `session update never changes account operation`() =
|
||||||
|
runTest(dispatcher) {
|
||||||
|
advanceUntilIdle()
|
||||||
|
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
|
||||||
|
vm.connect("drive")
|
||||||
|
advanceUntilIdle()
|
||||||
|
events.emit(
|
||||||
|
WsEvent.ConnectionUpdate(
|
||||||
|
snapshot().copy(seq = 3, settled = true, accountOwned = false, sessionId = "chat"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(
|
||||||
|
1L,
|
||||||
|
vm.connectionState.value.operation
|
||||||
|
?.seq,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshot() =
|
||||||
|
checkNotNull(
|
||||||
|
ConnectionOperationParser.parse(
|
||||||
|
mapOf(
|
||||||
|
"owner" to mapOf("type" to "account"),
|
||||||
|
"op_id" to "op-a",
|
||||||
|
"seq" to 1,
|
||||||
|
"deadline_at" to 2000000000.0,
|
||||||
|
"settled" to false,
|
||||||
|
"targets" to
|
||||||
|
listOf(
|
||||||
|
mapOf(
|
||||||
|
"name" to "drive",
|
||||||
|
"kind" to "connector",
|
||||||
|
"action" to "authorize",
|
||||||
|
"state" to "initiated",
|
||||||
|
"connect_url" to "https://example.com/auth",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user