feat(connectors): surface active session integrations (#1091) (#1100)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 13:41:13 +03:00
1 parent 29bc367aec
commit 52e0104cd2
23 files changed
+6579 -6

No files matched your search

@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.m57.hermescontrol.data.config.ConnectionProfile
@@ -500,6 +501,11 @@ object AuthManager {
_baseUrlFlow.value = ""
}
@VisibleForTesting
fun setBaseUrlForTest(baseUrl: String) {
_baseUrlFlow.value = baseUrl
}
fun getToken(): String? {
if (tokenInitialized) return cachedToken
synchronized(this) {
@@ -0,0 +1,233 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.util.ConnectorUrlValidator
/**
* High-level connection status for a connector item.
* Deliberate precedence: if [connected] is true, status resolves to [CONNECTED]
* regardless of expired/revoked/stale statuses reported by backend.
*/
enum class ConnectorStatus {
CONNECTED,
INITIATED,
EXPIRED,
REVOKED,
FAILED,
DISCONNECTED,
UNKNOWN,
;
val isConnected: Boolean get() = this == CONNECTED
companion object {
fun from(
connected: Boolean,
rawStatus: String?,
): ConnectorStatus = from(connected = connected as Boolean?, rawStatus = rawStatus)
fun from(
connected: Boolean?,
rawStatus: String?,
): ConnectorStatus {
if (connected == true) return CONNECTED
if (connected == false) {
return when (rawStatus?.lowercase()?.trim()) {
"expired" -> EXPIRED
"revoked" -> REVOKED
"initiated" -> INITIATED
"failed", "error" -> FAILED
"disconnected" -> DISCONNECTED
null, "", "active", "connected", "authorized" -> DISCONNECTED
else -> UNKNOWN
}
}
// Missing boolean: "authorized", "active", or "connected" yields effective CONNECTED
return when (rawStatus?.lowercase()?.trim()) {
"authorized", "active", "connected" -> CONNECTED
"expired" -> EXPIRED
"revoked" -> REVOKED
"initiated" -> INITIATED
"failed", "error" -> FAILED
"disconnected" -> DISCONNECTED
null, "" -> DISCONNECTED
else -> UNKNOWN
}
}
}
}
/**
* Status resulting from a connect/reconnect initiation.
*/
enum class ConnectorConnectStatus {
ACTIVE,
INITIATED,
FAILED,
UNKNOWN,
;
val isActive: Boolean get() = this == ACTIVE
companion object {
fun from(rawStatus: String?): ConnectorConnectStatus =
when (rawStatus?.lowercase()?.trim()) {
"active", "connected" -> ACTIVE
"initiated" -> INITIATED
"failed", "error" -> FAILED
null, "" -> UNKNOWN
else -> UNKNOWN
}
}
}
/**
* Individual connector item returned by `connectors.list`.
*/
data class ConnectorItem(
val connector: String,
val name: String = connector,
val connected: Boolean = false,
val enabled: Boolean = true,
val connectionStatus: String? = null,
val status: ConnectorStatus = ConnectorStatus.from(connected, connectionStatus),
val connectUrl: String? = null,
) {
val isConnected: Boolean get() = connected || status.isConnected
/** Safe validated HTTPS URL, or null if invalid or absent. */
val safeConnectUrl: String?
get() = if (ConnectorUrlValidator.isValidHttpsUrl(connectUrl)) connectUrl else null
override fun toString(): String {
val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null"
return "ConnectorItem(" +
"connector=$connector, " +
"name=$name, " +
"connected=$connected, " +
"enabled=$enabled, " +
"status=$status, " +
"connectUrl=$redactedUrl" +
")"
}
}
/**
* Result of a connect/reconnect action for an individual connector.
* Instructions from arbitrary backends are intentionally not exposed for UI display.
*/
data class ConnectorConnectItem(
val connector: String,
val status: ConnectorConnectStatus,
val connectUrl: String? = null,
) {
val isConnectUrlValid: Boolean
get() = ConnectorUrlValidator.isValidHttpsUrl(connectUrl)
val safeConnectUrl: String?
get() = if (isConnectUrlValid) connectUrl else null
override fun toString(): String {
val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null"
return "ConnectorConnectItem(" +
"connector=$connector, " +
"status=$status, " +
"connectUrl=$redactedUrl, " +
"isConnectUrlValid=$isConnectUrlValid" +
")"
}
}
/**
* Summary counts returned by `connectors.connect`.
*/
data class ConnectorConnectSummary(
val total: Int = 0,
val active: Int = 0,
val initiated: Int = 0,
val failed: Int = 0,
)
/**
* Typed safe connector errors mapped from RPC codes and reasons.
* Raw exceptions or auth tokens are never exposed.
*/
sealed interface ConnectorError {
val message: String
data class InvalidParams(
override val message: String = "Invalid connector parameters.",
) : ConnectorError
data class NotOwner(
override val message: String = "Session is not owned by this transport.",
) : ConnectorError
data class Unavailable(
override val message: String = "Connectors are not available in this session.",
) : ConnectorError
data class UnsupportedRuntime(
override val message: String = "Connectors must be managed on the session's compute host.",
) : ConnectorError
data class RequestFailed(
override val message: String = "Connector request failed. Please try again.",
) : ConnectorError
data class InvalidResponse(
override val message: String = "Connector service returned an invalid response.",
) : ConnectorError
data class UnsupportedBackend(
override val message: String = "Connector service is not supported by this backend.",
) : ConnectorError
data class MalformedEnvelope(
override val message: String = "Malformed response envelope received from gateway.",
) : ConnectorError
data class NetworkError(
override val message: String = "Failed to communicate with gateway.",
) : ConnectorError
data class Other(
val code: Int = 0,
override val message: String = "Connector operation failed.",
) : ConnectorError
}
/**
* Result envelope for `connectors.list`.
*/
sealed interface ConnectorListResult {
data class Success(
val available: Boolean,
val connectors: List<ConnectorItem>,
) : ConnectorListResult
data class Error(
val error: ConnectorError,
) : ConnectorListResult
fun getOrNull(): List<ConnectorItem>? = (this as? Success)?.connectors
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
}
/**
* Result envelope for `connectors.connect`.
*/
sealed interface ConnectorConnectResult {
data class Success(
val results: List<ConnectorConnectItem>,
val summary: ConnectorConnectSummary,
) : ConnectorConnectResult
data class Error(
val error: ConnectorError,
) : ConnectorConnectResult
fun getOrNull(): List<ConnectorConnectItem>? = (this as? Success)?.results
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
}
@@ -0,0 +1,389 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectorConnectItem
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorConnectStatus
import com.m57.hermescontrol.data.model.ConnectorConnectSummary
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorItem
import com.m57.hermescontrol.data.model.ConnectorListResult
import com.m57.hermescontrol.data.model.ConnectorStatus
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
/**
* Dedicated parser for connector payloads with strict envelope validation,
* resilient type coercion, legacy alias normalization, and safe error mapping.
*/
object ConnectorParser {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Parse raw result from `connectors.list` RPC.
* Fails with [ConnectorError.MalformedEnvelope] if the required envelope structure is missing.
*/
fun parseListResult(raw: Any?): ConnectorListResult {
val root =
toJsonElement(raw) as? JsonObject
?: return ConnectorListResult.Error(
ConnectorError.MalformedEnvelope("Response envelope must be a JSON object."),
)
// Required envelope check: "connectors" must exist and be an array.
val connectorsArray =
root["connectors"] as? JsonArray
?: return ConnectorListResult.Error(
ConnectorError.MalformedEnvelope("Envelope missing required 'connectors' array."),
)
// Optional 'available' flag (defaults to true if omitted, unless explicitly false)
val available = parseBoolean(root["available"], default = true)
val items = mutableListOf<ConnectorItem>()
val seenSlugs = mutableSetOf<String>()
for (element in connectorsArray) {
val itemObj = element as? JsonObject ?: continue
val item = parseConnectorItem(itemObj) ?: continue
if (seenSlugs.add(item.connector)) {
items.add(item)
}
}
// Entirely invalid nonempty connectors array must fail rather than looking silently valid empty.
if (connectorsArray.isNotEmpty() && items.isEmpty()) {
return ConnectorListResult.Error(
ConnectorError.MalformedEnvelope("Connectors list contains only invalid items."),
)
}
return ConnectorListResult.Success(
available = available,
connectors = items,
)
}
/**
* Parse raw result from `connectors.connect` RPC.
* Fails with [ConnectorError.MalformedEnvelope] or [ConnectorError.InvalidResponse] if invalid.
*/
fun parseConnectResult(raw: Any?): ConnectorConnectResult {
val rawElement = toJsonElement(raw)
// Tolerate purposeful legacy bare array form: [ { "connector": "linear", ... } ]
if (rawElement is JsonArray) {
val results = mutableListOf<ConnectorConnectItem>()
val seenSlugs = mutableSetOf<String>()
for (element in rawElement) {
val itemObj = element as? JsonObject ?: continue
val item = parseConnectItem(itemObj) ?: continue
if (seenSlugs.add(item.connector)) {
results.add(item)
}
}
if (results.isNotEmpty()) {
val summary =
ConnectorConnectSummary(
total = results.size,
active = results.count { it.status == ConnectorConnectStatus.ACTIVE },
initiated = results.count { it.status == ConnectorConnectStatus.INITIATED },
failed = results.count { it.status == ConnectorConnectStatus.FAILED },
)
return ConnectorConnectResult.Success(
results = results,
summary = summary,
)
}
return ConnectorConnectResult.Error(
ConnectorError.InvalidResponse("Connect response contains no valid results."),
)
}
val root =
rawElement as? JsonObject
?: return ConnectorConnectResult.Error(
ConnectorError.MalformedEnvelope("Connect response must be a JSON object."),
)
// Required envelope check: "results" must exist and be an array.
val resultsArray =
root["results"] as? JsonArray
?: return ConnectorConnectResult.Error(
ConnectorError.MalformedEnvelope("Connect envelope missing required 'results' array."),
)
val results = mutableListOf<ConnectorConnectItem>()
val seenSlugs = mutableSetOf<String>()
for (element in resultsArray) {
val itemObj = element as? JsonObject ?: continue
val item = parseConnectItem(itemObj) ?: continue
if (seenSlugs.add(item.connector)) {
results.add(item)
}
}
// Connect must return at least nonempty valid results
if (results.isEmpty()) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidResponse("Connect response contains no valid results."),
)
}
// Current summary object is required in standard envelope
val summaryObj =
root["summary"] as? JsonObject
?: return ConnectorConnectResult.Error(
ConnectorError.MalformedEnvelope("Connect envelope missing required 'summary' object."),
)
val summary = parseConnectSummary(summaryObj)
return ConnectorConnectResult.Success(
results = results,
summary = summary,
)
}
private fun parseConnectorItem(obj: JsonObject): ConnectorItem? {
// Strict string identifier with backend slug validation; legacy aliases supported
val slug =
parseIdentifier(obj["connector"])
?: parseIdentifier(obj["slug"])
?: parseIdentifier(obj["id"])
?: return null // Skip items without valid connector identifier
// Display name: primary "name", legacy aliases "displayName", "title", fallback to slug
val name =
parseString(obj["name"])
?: parseString(obj["displayName"])
?: parseString(obj["title"])
?: slug
val rawConnected = parseBooleanOrNull(obj["connected"])
val enabled = parseBoolean(obj["enabled"], default = true)
// Connection status string: primary "connectionStatus", legacy aliases "status", "connection_status"
val rawStatus =
parseString(obj["connectionStatus"])
?: parseString(obj["status"])
?: parseString(obj["connection_status"])
// Compute typed status respecting connected precedence:
// When connected boolean is missing (legacy), status "authorized"/"connected"/"active" yields CONNECTED.
// Explicit connected=false is authoritative and not overridden by status active.
val status = ConnectorStatus.from(connected = rawConnected, rawStatus = rawStatus)
val effectiveConnected = rawConnected ?: status.isConnected
// Connect URL (transient): primary "connect_url", legacy aliases "connectUrl", "auth_url", "authUrl"
val connectUrl =
parseString(obj["connect_url"])
?: parseString(obj["connectUrl"])
?: parseString(obj["auth_url"])
?: parseString(obj["authUrl"])
return ConnectorItem(
connector = slug,
name = name,
connected = effectiveConnected,
enabled = enabled,
connectionStatus = rawStatus,
status = status,
connectUrl = connectUrl,
)
}
private fun parseConnectItem(obj: JsonObject): ConnectorConnectItem? {
val slug =
parseIdentifier(obj["connector"])
?: parseIdentifier(obj["slug"])
?: parseIdentifier(obj["id"])
?: return null
val rawStatus =
parseString(obj["status"])
?: parseString(obj["connectionStatus"])
?: parseString(obj["connection_status"])
val status = ConnectorConnectStatus.from(rawStatus)
val connectUrl =
parseString(obj["connect_url"])
?: parseString(obj["connectUrl"])
?: parseString(obj["auth_url"])
?: parseString(obj["authUrl"])
return ConnectorConnectItem(
connector = slug,
status = status,
connectUrl = connectUrl,
)
}
private fun parseConnectSummary(obj: JsonObject?): ConnectorConnectSummary {
if (obj == null) return ConnectorConnectSummary()
return ConnectorConnectSummary(
total = parseInt(obj["total"], default = 0),
active = parseInt(obj["active"], default = 0),
initiated = parseInt(obj["initiated"], default = 0),
failed = parseInt(obj["failed"], default = 0),
)
}
/**
* Map RPC error codes and reasons into typed [ConnectorError]s.
* Raw server strings and sensitive parameters are never forwarded or exposed in error messages.
*/
fun mapRpcError(
code: Int,
message: String? = null,
data: JsonElement? = null,
): ConnectorError {
val reason =
when (data) {
is JsonObject -> (data["reason"] as? JsonPrimitive)?.content
is JsonPrimitive -> data.content
else -> null
}?.uppercase()?.trim()
return when {
code == 4000 || reason == "INVALID_PARAMS" -> {
ConnectorError.InvalidParams()
}
code == 4001 || reason == "NOT_OWNER" -> {
ConnectorError.NotOwner()
}
code == 4031 || reason == "CONNECTORS_UNAVAILABLE" -> {
ConnectorError.Unavailable()
}
code == 5033 || reason == "UNSUPPORTED_RUNTIME" -> {
ConnectorError.UnsupportedRuntime()
}
code == 5034 && reason == "INVALID_CONNECTOR_RESPONSE" -> {
ConnectorError.InvalidResponse()
}
code == 5034 -> {
ConnectorError.RequestFailed()
}
code == -32601 -> {
ConnectorError.UnsupportedBackend()
}
else -> {
ConnectorError.Other(code = code)
}
}
}
internal fun parseStrictString(element: JsonElement?): String? {
if (element == null || element is JsonNull) return null
if (element is JsonPrimitive && element.isString) {
return element.content
}
return null
}
internal fun parseIdentifier(element: JsonElement?): String? {
val str = parseStrictString(element)?.trim() ?: return null
return if (ConnectorRepository.isValidSlug(str)) str else null
}
internal fun parseString(element: JsonElement?): String? {
if (element == null || element is JsonNull) return null
if (element is JsonPrimitive) {
if (!element.isString && element.content.isBlank()) return null
return element.content
}
return null
}
internal fun parseBooleanOrNull(element: JsonElement?): Boolean? {
if (element == null || element is JsonNull) return null
if (element is JsonPrimitive) {
element.booleanOrNull?.let { return it }
val content = element.content.trim()
if (content.equals("true", ignoreCase = true) || content == "1") return true
if (content.equals("false", ignoreCase = true) || content == "0") return false
}
return null
}
internal fun parseBoolean(
element: JsonElement?,
default: Boolean,
): Boolean = parseBooleanOrNull(element) ?: default
internal fun parseInt(
element: JsonElement?,
default: Int,
): Int {
if (element == null || element is JsonNull) return default
if (element is JsonPrimitive) {
element.intOrNull?.let { return it }
element.content.toIntOrNull()?.let { return it }
}
return default
}
fun toJsonElement(value: Any?): JsonElement =
when (value) {
null -> {
JsonNull
}
is JsonElement -> {
value
}
is Map<*, *> -> {
JsonObject(
@Suppress("UNCHECKED_CAST")
(value as Map<String, Any?>).mapValues { (_, v) -> toJsonElement(v) },
)
}
is List<*> -> {
JsonArray(value.map { toJsonElement(it) })
}
is String -> {
val trimmed = value.trim()
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
try {
json.parseToJsonElement(value)
} catch (_: Exception) {
JsonPrimitive(value)
}
} else {
JsonPrimitive(value)
}
}
is Boolean -> {
JsonPrimitive(value)
}
is Number -> {
JsonPrimitive(value)
}
else -> {
JsonPrimitive(value.toString())
}
}
}
@@ -0,0 +1,126 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorListResult
import kotlinx.coroutines.CancellationException
/**
* Repository interface for connector operations: `connectors.list` and `connectors.connect` (issue #1091).
* Public injectable interface enabling isolated testing in ViewModels and controllers.
*/
interface ConnectorRepository {
/**
* Request connector catalog and authorization status for [sessionId].
*/
suspend fun listConnectors(sessionId: String): ConnectorListResult
/**
* Initiate authorization flow for [connectors] slugs in [sessionId].
* If [reconnect] is true, restarts authorization flow even if already connected.
*/
suspend fun connect(
sessionId: String,
connectors: List<String>,
reconnect: Boolean = false,
): ConnectorConnectResult
companion object : ConnectorRepository by HermesConnectorRepository() {
private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]*$")
fun isValidSlug(slug: String?): Boolean {
if (slug.isNullOrBlank()) return false
return SLUG_REGEX.matches(slug)
}
}
}
/**
* Default implementation of [ConnectorRepository] backed by [HermesWsClient].
*/
class HermesConnectorRepository(
private val rpcRequest: suspend (method: String, params: 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()
}
}
},
) : ConnectorRepository {
override suspend fun listConnectors(sessionId: String): ConnectorListResult {
if (sessionId.isBlank()) {
return ConnectorListResult.Error(
ConnectorError.InvalidParams("session_id required"),
)
}
return try {
val params = mapOf("session_id" to sessionId)
val result = rpcRequest(WsMethods.CONNECTORS_LIST, params)
ConnectorParser.parseListResult(result)
} catch (e: CancellationException) {
throw e
} catch (e: HermesWsClient.HermesRpcException) {
ConnectorListResult.Error(
ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data),
)
} catch (_: Exception) {
ConnectorListResult.Error(
ConnectorError.NetworkError("Failed to communicate with gateway."),
)
}
}
override suspend fun connect(
sessionId: String,
connectors: List<String>,
reconnect: Boolean,
): ConnectorConnectResult {
if (sessionId.isBlank()) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidParams("session_id required"),
)
}
if (connectors.isEmpty()) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"),
)
}
for (slug in connectors) {
if (!ConnectorRepository.isValidSlug(slug)) {
return ConnectorConnectResult.Error(
ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"),
)
}
}
return try {
val params =
mapOf(
"session_id" to sessionId,
"connectors" to connectors,
"reconnect" to reconnect,
)
val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params)
ConnectorParser.parseConnectResult(result)
} catch (e: CancellationException) {
throw e
} catch (e: HermesWsClient.HermesRpcException) {
ConnectorConnectResult.Error(
ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data),
)
} catch (_: Exception) {
ConnectorConnectResult.Error(
ConnectorError.NetworkError("Failed to communicate with gateway."),
)
}
}
}
@@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -30,6 +31,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
@@ -702,6 +705,8 @@ object HermesWsClient {
/** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */
class HermesRpcException(
message: String,
val code: Int = 0,
val data: JsonElement? = null,
) : Exception(message)
/**
@@ -741,6 +746,15 @@ object HermesWsClient {
send(method, params) { reqId ->
pendingCalls[reqId] = PendingCall(method, deferred)
}
deferred.invokeOnCompletion { cause ->
if (cause is CancellationException) {
pendingCalls.remove(id)?.let { call ->
call.timeoutJob?.cancel()
removeQueuedMessage(id)
disconnectIfIdleInBackground()
}
}
}
// Arm the per-request timeout (fires if the server never answers).
pendingCalls[id]?.timeoutJob =
wsScope.launch {
@@ -760,7 +774,13 @@ object HermesWsClient {
removeQueuedMessage(id)
call.timeoutJob?.cancel()
if (error != null) {
call.deferred.completeExceptionally(HermesRpcException(error.message))
call.deferred.completeExceptionally(
HermesRpcException(
message = error.message,
code = error.code,
data = error.data,
),
)
} else {
call.deferred.complete(result)
}
@@ -808,7 +828,9 @@ object HermesWsClient {
params = decoratedParams.mapValues { it.value.toJsonElement() },
)
val json = OkHttpProvider.json.encodeToString(request)
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
if (BuildConfig.DEBUG) {
Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false))
}
var reconnect = false
synchronized(outboundLock) {
if (method == WsMethods.PROMPT_SUBMIT) {
@@ -1191,7 +1213,7 @@ object HermesWsClient {
markQueuedMessageSent(msg)
continue
}
if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg")
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg))
if (!webSocket.send(msg)) {
recoverRejectedSocket(webSocket)
break
@@ -1209,7 +1231,7 @@ object HermesWsClient {
text: String,
) {
if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text))
lastPongTimestamp = monotonicTimeMs()
// Resolve any in-flight `request()` awaiting this RPC result/error
// (issue #526) before fanning the parsed event out to collectors.
@@ -1269,7 +1291,7 @@ object HermesWsClient {
parsed
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}")
WsEvent.Unknown(text)
}
when (event) {
@@ -1381,4 +1403,60 @@ object HermesWsClient {
}
}
}
@VisibleForTesting
internal fun setConnectionStatusForTest(status: ConnectionStatus) {
_connectionStatus.value = status
}
@VisibleForTesting
internal fun formatSafeOutgoingFrameLog(
id: String,
method: String,
paramsKeys: Collection<String>?,
byteLength: Int,
queued: Boolean = false,
): String {
val prefix = if (queued) "→ (queued)" else "→"
val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]"
return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength"
}
@VisibleForTesting
internal fun formatSafeQueuedFrameLog(msg: String): String =
try {
val req = OkHttpProvider.json.decodeFromString<JsonRpcRequest>(msg)
formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true)
} catch (_: Throwable) {
"→ (queued) frame bytes=${msg.length}"
}
@VisibleForTesting
internal fun formatSafeIncomingFrameLog(text: String): String =
try {
val element = OkHttpProvider.json.parseToJsonElement(text)
if (element is JsonObject) {
val id = (element["id"] as? JsonPrimitive)?.content
val method = (element["method"] as? JsonPrimitive)?.content
val hasResult = element.containsKey("result")
val resultKeys =
(element["result"] as? JsonObject)?.keys?.joinToString(
",",
prefix = "[",
postfix = "]",
)
val errorObj = element["error"] as? JsonObject
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content
val sb = StringBuilder("← ")
if (id != null) sb.append("id=").append(id).append(" ")
if (method != null) sb.append("method=").append(method).append(" ")
if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ")
if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ")
sb.append("bytes=").append(text.length).toString()
} else {
"← non-object frame bytes=${text.length}"
}
} catch (_: Throwable) {
"← frame bytes=${text.length}"
}
}
@@ -91,6 +91,14 @@ object WsMethods {
/** Rolling tail snapshot of a subagent's live execution transcript. */
const val SUBAGENT_TAIL = "subagent.tail"
// ── Connectors (issue #1091) ───────────────────────────────────────────
/** 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"
// ── Billing / subscription (issue #628) ─────────────────────────────
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
@@ -0,0 +1,942 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorConnectStatus
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorItem
import com.m57.hermescontrol.data.model.ConnectorListResult
import com.m57.hermescontrol.data.model.ConnectorStatus
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.ConnectorRepository
import com.m57.hermescontrol.util.ConnectorUrlValidator
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Explicit load phases for connector list rendering.
*/
sealed interface ConnectorsLoadPhase {
data object Initial : ConnectorsLoadPhase
data object Loading : ConnectorsLoadPhase
data class Refreshing(
val previousItems: List<ConnectorUiItem>,
) : ConnectorsLoadPhase
data class Loaded(
val available: Boolean,
) : ConnectorsLoadPhase
data class Error(
val error: ConnectorError,
val canRetry: Boolean = true,
) : ConnectorsLoadPhase
data class Unavailable(
val message: String,
) : ConnectorsLoadPhase
data class UnsupportedRuntime(
val message: String,
) : ConnectorsLoadPhase
data class UnsupportedBackend(
val message: String,
) : ConnectorsLoadPhase
data class NotOwner(
val message: String,
) : ConnectorsLoadPhase
}
/**
* Mutating action phases for individual connector operations.
*/
sealed interface ConnectorActionState {
data object Idle : ConnectorActionState
data class Connecting(
val slug: String,
val isReconnect: Boolean,
) : ConnectorActionState
data class AwaitingAuthorization(
val slug: String,
) : ConnectorActionState
}
/**
* Immutable safe UI item representation. Raw connect URLs are strictly omitted.
*/
data class ConnectorUiItem(
val slug: String,
val name: String,
val isConnected: Boolean,
val isEnabled: Boolean,
val status: ConnectorStatus,
val connectionStatus: String?,
) {
companion object {
fun from(item: ConnectorItem): ConnectorUiItem =
ConnectorUiItem(
slug = item.connector,
name = item.name,
isConnected = item.connected,
isEnabled = item.enabled,
status = item.status,
connectionStatus = item.connectionStatus,
)
}
}
/**
* Transient one-shot browser launch event. Redacts URL in [toString].
*/
data class ConnectBrowserEvent(
val slug: String,
val url: String,
val eventId: Long = System.nanoTime(),
val sessionId: String? = null,
val generation: Long = 0L,
) {
override fun toString(): String =
"ConnectBrowserEvent(slug=$slug, url=[REDACTED], eventId=$eventId, sessionId=$sessionId, generation=$generation)"
}
/**
* Complete UI state exposed to the session integrations view.
*/
data class ChatConnectorsUiState(
val isVisible: Boolean = false,
val loadPhase: ConnectorsLoadPhase = ConnectorsLoadPhase.Initial,
val items: List<ConnectorUiItem> = emptyList(),
val actionState: ConnectorActionState = ConnectorActionState.Idle,
val browserLaunchEvent: ConnectBrowserEvent? = null,
val errorMessage: String? = null,
val isOldBackend: Boolean = false,
val sessionId: String? = null,
val generation: Long = 0L,
) {
val isLoading: Boolean
get() = loadPhase is ConnectorsLoadPhase.Loading || actionState is ConnectorActionState.Connecting
}
/**
* Controller / Delegate managing session Integrations (connectors) state,
* lifecycle, deduplication, and authorization flows (issue #1091).
*/
class ChatConnectorsDelegate(
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val repository: ConnectorRepository = ConnectorRepository,
private val runtimeSessionId: () -> String? = { ActiveSessionHolder.activeSessionId.value },
) {
private val _uiState = MutableStateFlow(ChatConnectorsUiState())
val uiState: StateFlow<ChatConnectorsUiState> = _uiState.asStateFlow()
private var isForeground: Boolean = false
private var sessionGeneration: Long = 0L
private var refreshRequestId: Long = 0L
private var refreshJob: Job? = null
private var connectJob: Job? = null
private var pendingRefreshAfterMutation: Boolean = false
private var pendingTrailingRefresh: Boolean = false
private var pendingBrowserEvent: ConnectBrowserEvent? = null
private var browserLaunched: Boolean = false
/**
* Show integrations panel and trigger load if in foreground.
*/
fun show() {
if (_uiState.value.isVisible) return
val sid = runtimeSessionId()
_uiState.update {
it.copy(
isVisible = true,
sessionId = sid,
generation = sessionGeneration,
)
}
if (isForeground) {
if (!sid.isNullOrBlank() && !_uiState.value.isOldBackend) {
refresh(force = false)
}
}
}
/**
* Hide integrations panel, cancelling ongoing jobs, incrementing generation,
* and clearing transient state.
*/
fun hide() {
sessionGeneration++
refreshJob?.cancel()
refreshJob = null
connectJob?.cancel()
connectJob = null
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
browserLaunched = false
_uiState.update {
it.copy(
isVisible = false,
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
generation = sessionGeneration,
)
}
}
/**
* Lifecycle resume hook. Refreshes once when returning from external browser authorization.
* Guarded to only execute on true paused -> resume transitions.
*/
fun onResume() {
val wasForeground = isForeground
isForeground = true
if (wasForeground) return
if (!_uiState.value.isVisible) return
val sid = runtimeSessionId()
val pending = pendingBrowserEvent
if (pending != null) {
pendingBrowserEvent = null
if (pending.generation == sessionGeneration &&
pending.sessionId == sid &&
_uiState.value.actionState is ConnectorActionState.AwaitingAuthorization
) {
_uiState.update { it.copy(browserLaunchEvent = pending) }
}
}
if (pendingRefreshAfterMutation || pendingTrailingRefresh) {
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
refresh(force = true)
return
}
val currentAction = _uiState.value.actionState
if (currentAction is ConnectorActionState.AwaitingAuthorization) {
if (browserLaunched) {
// Actual return from external browser authorization: refresh once to confirm authorization
browserLaunched = false
refresh(force = true)
return
}
// Minted unconsumed event waiting to be launched: do NOT prematurely refresh
return
}
// Returned from background: initial load or reload interrupted read if needed and not old backend
val currentPhase = _uiState.value.loadPhase
val needsCatalogLoad =
(
currentPhase is ConnectorsLoadPhase.Initial ||
currentPhase is ConnectorsLoadPhase.Loading ||
currentPhase is ConnectorsLoadPhase.Refreshing
) &&
refreshJob?.isActive != true &&
!_uiState.value.isOldBackend
if (needsCatalogLoad) {
refresh(force = false)
}
}
/**
* Lifecycle pause hook. Retains unconsumed browser event bound to session + generation.
*/
fun onPause() {
isForeground = false
val unconsumed = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent
if (unconsumed != null &&
unconsumed.generation == sessionGeneration &&
unconsumed.sessionId == runtimeSessionId()
) {
pendingBrowserEvent = unconsumed
}
_uiState.update { it.copy(browserLaunchEvent = null) }
}
/**
* Notify of active session change with generation guard (A -> B -> A).
*/
fun onActiveSessionChanged(newSessionId: String?) {
sessionGeneration++
val currentGen = sessionGeneration
refreshJob?.cancel()
refreshJob = null
connectJob?.cancel()
connectJob = null
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
browserLaunched = false
if (newSessionId.isNullOrBlank()) {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Initial,
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
isOldBackend = false,
sessionId = null,
generation = currentGen,
)
}
return
}
if (_uiState.value.isVisible && isForeground) {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Loading,
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
isOldBackend = false,
sessionId = newSessionId,
generation = currentGen,
)
}
executeRefresh(newSessionId, currentGen)
} else {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Initial,
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
isOldBackend = false,
sessionId = newSessionId,
generation = currentGen,
)
}
}
}
/**
* Transport reconnected notification. Refreshes if visible, foreground, and backend supports connectors.
*/
fun onTransportReconnected() {
if (_uiState.value.isVisible && isForeground && !_uiState.value.isOldBackend) {
val sessionId = runtimeSessionId()
if (!sessionId.isNullOrBlank()) {
refresh(force = true)
}
}
}
/**
* Request connector catalog and authorization status.
* Deduplicated and coalesced for both normal and forced calls, and blocked in background.
*/
fun refresh(force: Boolean = false) {
if (!_uiState.value.isVisible || !isForeground) return
if (_uiState.value.isOldBackend && !force) return
val sessionId = runtimeSessionId()
if (sessionId.isNullOrBlank()) return
if (_uiState.value.actionState is ConnectorActionState.Connecting) {
pendingRefreshAfterMutation = true
return
}
if (refreshJob?.isActive == true) {
if (force) {
pendingTrailingRefresh = true
}
return
}
val gen = sessionGeneration
val previousItems = _uiState.value.items
_uiState.update { current ->
if (previousItems.isEmpty()) {
current.copy(
loadPhase = ConnectorsLoadPhase.Loading,
sessionId = sessionId,
generation = gen,
)
} else {
current.copy(
loadPhase = ConnectorsLoadPhase.Refreshing(previousItems),
sessionId = sessionId,
generation = gen,
)
}
}
executeRefresh(sessionId, gen)
}
private fun executeRefresh(
targetSessionId: String,
generation: Long,
) {
val requestId = ++refreshRequestId
refreshJob =
scope.launch {
var currentSession = targetSessionId
try {
var result = withContext(ioDispatcher) { repository.listConnectors(currentSession) }
// Bounded NOT_OWNER reconciliation: re-read active session once (READ ONLY)
if (result is ConnectorListResult.Error && result.error is ConnectorError.NotOwner) {
val freshSession = runtimeSessionId()
if (!freshSession.isNullOrBlank() && freshSession != currentSession) {
currentSession = freshSession
result = withContext(ioDispatcher) { repository.listConnectors(currentSession) }
}
}
// Check target sid AND generation AND request token AND visibility before applying
val activeSid = runtimeSessionId()
if (requestId != refreshRequestId ||
generation != sessionGeneration ||
currentSession != activeSid ||
!_uiState.value.isVisible
) {
return@launch
}
when (result) {
is ConnectorListResult.Success -> {
if (!result.available) {
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
_uiState.update { current ->
current.copy(
loadPhase =
ConnectorsLoadPhase.Unavailable(
"Connectors are unavailable for this session.",
),
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
sessionId = currentSession,
generation = generation,
)
}
return@launch
}
val uiItems = result.connectors.map { ConnectorUiItem.from(it) }
_uiState.update { current ->
val currentAction = current.actionState
val updatedAction =
if (currentAction is ConnectorActionState.AwaitingAuthorization) {
val matchingItem = uiItems.find { it.slug == currentAction.slug }
// End authorization if connector removed from catalog or now connected
if (matchingItem == null || matchingItem.isConnected) {
ConnectorActionState.Idle
} else {
currentAction
}
} else {
currentAction
}
current.copy(
loadPhase = ConnectorsLoadPhase.Loaded(result.available),
items = uiItems,
actionState = updatedAction,
isOldBackend = false,
errorMessage = null, // error clears on refresh success
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorListResult.Error -> {
when (val err = result.error) {
is ConnectorError.UnsupportedBackend -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message),
isOldBackend = true,
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorError.Unavailable -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Unavailable(err.message),
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorError.UnsupportedRuntime -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message),
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorError.NotOwner -> {
val notOwnerMsg =
"Session is not owned by this transport. Please reconnect the session."
_uiState.update {
it.copy(
loadPhase =
ConnectorsLoadPhase.NotOwner(
notOwnerMsg,
),
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
else -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Error(err, canRetry = true),
sessionId = currentSession,
generation = generation,
)
}
}
}
}
}
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
_uiState.update {
it.copy(
loadPhase =
ConnectorsLoadPhase.Error(
ConnectorError.NetworkError(t.message ?: "Failed to load connectors"),
canRetry = true,
),
sessionId = currentSession,
generation = generation,
)
}
} finally {
refreshJob = null
if (pendingTrailingRefresh &&
generation == sessionGeneration &&
_uiState.value.isVisible
) {
if (isForeground) {
pendingTrailingRefresh = false
refresh(force = true)
}
} else {
pendingTrailingRefresh = false
}
}
}
}
/**
* Initiate authorization flow for connector [slug].
* Guarded by visibility, foreground state, catalog readiness, and item enabled status.
* Never replays connect mutations into a different session.
*/
fun connect(
slug: String,
reconnect: Boolean = false,
) {
if (!_uiState.value.isVisible || !isForeground) return
if (_uiState.value.isOldBackend || _uiState.value.loadPhase is ConnectorsLoadPhase.UnsupportedBackend) {
_uiState.update { it.copy(errorMessage = "Connectors are not supported by this backend.") }
return
}
val currentPhase = _uiState.value.loadPhase
if (currentPhase !is ConnectorsLoadPhase.Loaded && currentPhase !is ConnectorsLoadPhase.Refreshing) {
_uiState.update { it.copy(errorMessage = "Cannot connect: connectors catalog is not ready.") }
return
}
val targetSession = runtimeSessionId()
if (targetSession.isNullOrBlank()) {
_uiState.update { it.copy(errorMessage = "No active session.") }
return
}
if (!ConnectorRepository.isValidSlug(slug)) {
_uiState.update { it.copy(errorMessage = "Invalid connector slug.") }
return
}
val catalogItem = _uiState.value.items.firstOrNull { it.slug == slug }
if (catalogItem == null) {
_uiState.update { it.copy(errorMessage = "Connector '$slug' not found in catalog.") }
return
}
if (!catalogItem.isEnabled) {
_uiState.update { it.copy(errorMessage = "Connector '$slug' is disabled.") }
return
}
if (_uiState.value.actionState is ConnectorActionState.Connecting) {
return
}
// Cancel any prior in-flight read before starting mutation
refreshJob?.cancel()
refreshJob = null
pendingTrailingRefresh = false
val gen = sessionGeneration
_uiState.update {
it.copy(
actionState = ConnectorActionState.Connecting(slug, reconnect),
errorMessage = null,
sessionId = targetSession,
generation = gen,
)
}
connectJob =
scope.launch {
var needsRefresh = false
try {
val result =
withContext(ioDispatcher) {
repository.connect(targetSession, listOf(slug), reconnect)
}
// Verify generation, session equality, and visibility before applying
val activeSid = runtimeSessionId()
if (gen != sessionGeneration ||
targetSession != activeSid ||
!_uiState.value.isVisible
) {
return@launch
}
when (result) {
is ConnectorConnectResult.Success -> {
// Require exact slug match; never fallback to unrelated connector
val item = result.results.firstOrNull { it.connector == slug }
if (item == null) {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Connector '$slug' not found in response.",
)
}
return@launch
}
when (item.status) {
ConnectorConnectStatus.ACTIVE -> {
_uiState.update { it.copy(actionState = ConnectorActionState.Idle) }
needsRefresh = true
}
ConnectorConnectStatus.INITIATED -> {
val safeUrl = item.safeConnectUrl
if (safeUrl != null && ConnectorUrlValidator.isValidHttpsUrl(safeUrl)) {
val event =
ConnectBrowserEvent(
slug = slug,
url = safeUrl,
sessionId = targetSession,
generation = gen,
)
browserLaunched = false
if (isForeground) {
_uiState.update {
it.copy(
actionState = ConnectorActionState.AwaitingAuthorization(slug),
browserLaunchEvent = event,
)
}
} else {
// In background: hold transiently for foreground resume
pendingBrowserEvent = event
_uiState.update {
it.copy(
actionState = ConnectorActionState.AwaitingAuthorization(slug),
browserLaunchEvent = null,
)
}
}
} else {
// Unsafe or absent URL is a retryable generic failure
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Invalid or unsafe authorization URL received.",
)
}
}
}
ConnectorConnectStatus.FAILED -> {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Connector authorization failed. Please try again.",
)
}
}
else -> {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Connector operation returned unexpected status.",
)
}
}
}
}
is ConnectorConnectResult.Error -> {
when (val err = result.error) {
is ConnectorError.UnsupportedBackend -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message),
actionState = ConnectorActionState.Idle,
isOldBackend = true,
items = emptyList(),
errorMessage = err.message,
)
}
}
is ConnectorError.Unavailable -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Unavailable(err.message),
actionState = ConnectorActionState.Idle,
items = emptyList(),
errorMessage = err.message,
)
}
}
is ConnectorError.UnsupportedRuntime -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message),
actionState = ConnectorActionState.Idle,
items = emptyList(),
errorMessage = err.message,
)
}
}
is ConnectorError.NotOwner -> {
val msg = "Session is not owned by this transport. Please reconnect the session."
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.NotOwner(msg),
actionState = ConnectorActionState.Idle,
errorMessage = msg,
)
}
}
else -> {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = err.message,
)
}
}
}
}
}
} catch (e: CancellationException) {
_uiState.update {
if (it.actionState is ConnectorActionState.Connecting) {
it.copy(actionState = ConnectorActionState.Idle)
} else {
it
}
}
throw e
} catch (t: Throwable) {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = t.message ?: "Connector connection failed.",
)
}
} finally {
connectJob = null
_uiState.update {
if (it.actionState is ConnectorActionState.Connecting) {
it.copy(actionState = ConnectorActionState.Idle)
} else {
it
}
}
val shouldRefresh = needsRefresh || pendingRefreshAfterMutation
if (shouldRefresh &&
gen == sessionGeneration &&
_uiState.value.isVisible
) {
if (isForeground) {
pendingRefreshAfterMutation = false
refresh(force = true)
} else {
pendingRefreshAfterMutation = true
}
} else if (gen != sessionGeneration || !_uiState.value.isVisible) {
pendingRefreshAfterMutation = false
}
}
}
}
/**
* Consume transient browser launch event (legacy no-arg API).
*/
fun consumeBrowserEvent() {
val currentSid = runtimeSessionId()
val event = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent
if (event != null &&
isForeground &&
_uiState.value.isVisible &&
event.generation == sessionGeneration &&
event.sessionId == currentSid
) {
browserLaunched = true
}
pendingBrowserEvent = null
_uiState.update { it.copy(browserLaunchEvent = null) }
}
/**
* Consume transient browser launch event validating identity, session, generation, and foreground state.
*/
fun consumeBrowserEvent(eventId: Long): ConnectBrowserEvent? {
var consumed: ConnectBrowserEvent? = null
val currentSid = runtimeSessionId()
val pending = pendingBrowserEvent
if (pending != null && pending.eventId == eventId) {
if (isForeground &&
_uiState.value.isVisible &&
pending.generation == sessionGeneration &&
pending.sessionId == currentSid
) {
consumed = pending
pendingBrowserEvent = null
} else {
pendingBrowserEvent = null
}
}
_uiState.update { current ->
val event = current.browserLaunchEvent
if (event != null && event.eventId == eventId) {
if (isForeground &&
current.isVisible &&
event.generation == sessionGeneration &&
event.sessionId == currentSid
) {
consumed = event
current.copy(browserLaunchEvent = null)
} else {
// Invalid context: clear stale event and reject handoff
current.copy(browserLaunchEvent = null)
}
} else {
current
}
}
if (consumed != null) {
browserLaunched = true
}
return consumed
}
/**
* Safely take transient browser launch event if valid.
*/
fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? {
val targetId =
eventId
?: _uiState.value.browserLaunchEvent?.eventId
?: pendingBrowserEvent?.eventId
?: return null
return consumeBrowserEvent(targetId)
}
/**
* Report an error launching the external browser intent.
*/
fun launchError(message: String) {
pendingBrowserEvent = null
browserLaunched = false
_uiState.update {
it.copy(
browserLaunchEvent = null,
actionState = ConnectorActionState.Idle,
errorMessage = message,
)
}
}
fun onLaunchError(message: String) = launchError(message)
/**
* Clear active error message.
*/
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
/**
* Teardown ongoing coroutines and invalidate state generation.
*/
fun destroy() {
sessionGeneration++
refreshJob?.cancel()
refreshJob = null
connectJob?.cancel()
connectJob = null
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
browserLaunched = false
}
}
@@ -0,0 +1,84 @@
package com.m57.hermescontrol.ui.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
/**
* Scoped ViewModel managing session connectors / integrations lifecycle (issue #1091).
*
* Hosts [ChatConnectorsDelegate] across configuration changes, wires [ActiveSessionHolder]
* and server/profile context transitions to invalidate context on changes, and forwards
* lifecycle and browser launch events safely.
*/
class ChatConnectorsViewModel(
delegate: ChatConnectorsDelegate? = null,
) : ViewModel() {
private val delegateInstance: ChatConnectorsDelegate =
delegate ?: ChatConnectorsDelegate(
scope = viewModelScope,
runtimeSessionId = { ActiveSessionHolder.activeSessionId.value },
)
val uiState: StateFlow<ChatConnectorsUiState> = delegateInstance.uiState
init {
var lastContext: Triple<String?, String?, String?>? = null
viewModelScope.launch {
combine(
ActiveSessionHolder.activeSessionId,
AuthManager.activeProfileId,
AuthManager.baseUrlFlow,
) { sessionId, profileId, baseUrl ->
Triple(sessionId, profileId, baseUrl)
}.collect { current ->
if (lastContext == null || current != lastContext) {
lastContext = current
delegateInstance.onActiveSessionChanged(current.first)
}
}
}
viewModelScope.launch {
HermesWsClient.connectionStatus
.filter { it == ConnectionStatus.CONNECTED }
.collect {
delegateInstance.onTransportReconnected()
}
}
}
fun show() = delegateInstance.show()
fun hide() = delegateInstance.hide()
fun onResume() = delegateInstance.onResume()
fun onPause() = delegateInstance.onPause()
fun refresh(force: Boolean = false) = delegateInstance.refresh(force)
fun connect(
slug: String,
reconnect: Boolean = false,
) = delegateInstance.connect(slug, reconnect)
fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? = delegateInstance.takeBrowserEvent(eventId)
fun launchError(message: String) = delegateInstance.launchError(message)
fun clearError() = delegateInstance.clearError()
override fun onCleared() {
super.onCleared()
delegateInstance.destroy()
}
}
@@ -1,6 +1,8 @@
package com.m57.hermescontrol.ui.chat
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -30,9 +32,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -44,6 +50,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -58,11 +65,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
@@ -90,6 +99,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
@@ -104,6 +114,7 @@ import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.util.ConnectorUrlValidator
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -143,10 +154,60 @@ fun ChatScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
var browserAuthDeparted by rememberSaveable { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> {
if (browserAuthInFlight) {
browserAuthDeparted = true
}
connectorsViewModel.onPause()
}
Lifecycle.Event.ON_RESUME -> {
connectorsViewModel.onResume()
if (browserAuthInFlight && browserAuthDeparted) {
browserAuthInFlight = false
browserAuthDeparted = false
ExternalActivityLifecycleGuard.externalActivityReturned()
}
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
val activity =
generateSequence(context) { (it as? ContextWrapper)?.baseContext }
.filterIsInstance<Activity>()
.firstOrNull()
val isChangingConfigs = activity?.isChangingConfigurations == true
if (!isChangingConfigs) {
if (browserAuthInFlight && browserAuthDeparted) {
browserAuthInFlight = false
browserAuthDeparted = false
ExternalActivityLifecycleGuard.externalActivityReturned()
}
connectorsViewModel.onPause()
connectorsViewModel.hide()
}
}
}
val browserEvent = connectorsState.browserLaunchEvent
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
val scrollScope = rememberCoroutineScope()
val scrollController = rememberChatScrollController(listState, scrollScope)
@@ -299,7 +360,6 @@ fun ChatScreen(
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
val context = LocalContext.current
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
@@ -310,6 +370,45 @@ fun ChatScreen(
)
}
val invalidResponseError = stringResource(R.string.session_integrations_err_invalid_response)
val browserLaunchError = stringResource(R.string.session_integrations_err_browser_launch)
LaunchedEffect(browserEvent) {
if (browserEvent != null) {
val eventId = browserEvent.eventId
val taken = connectorsViewModel.takeBrowserEvent(eventId)
if (taken != null) {
if (!ConnectorUrlValidator.isValidHttpsUrl(taken.url)) {
connectorsViewModel.launchError(invalidResponseError)
return@LaunchedEffect
}
try {
val intent =
Intent(Intent.ACTION_VIEW, Uri.parse(taken.url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
launchExternalActivity {
browserAuthDeparted = false
browserAuthInFlight = true
context.startActivity(intent)
}
} catch (_: ActivityNotFoundException) {
browserAuthInFlight = false
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError)
} catch (_: SecurityException) {
browserAuthInFlight = false
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError)
} catch (_: Exception) {
browserAuthInFlight = false
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError)
}
}
}
}
val mediaLaunchers =
rememberChatMediaLaunchers(
inputFieldValue = inputFieldValue,
@@ -428,6 +527,39 @@ fun ChatScreen(
contentDescription = stringResource(R.string.content_desc_new_chat),
)
}
// Session actions overflow menu (issue #1091)
var showSessionMenu by remember { mutableStateOf(false) }
Box {
IconButton(
onClick = { showSessionMenu = true },
modifier = Modifier.testTag("chat_session_menu_button"),
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.session_integrations_menu_item),
)
}
DropdownMenu(
expanded = showSessionMenu,
onDismissRequest = { showSessionMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
)
},
onClick = {
showSessionMenu = false
connectorsViewModel.show()
},
modifier = Modifier.testTag("chat_menu_session_integrations"),
)
}
}
},
) { _ ->
Column(
@@ -763,5 +895,14 @@ fun ChatScreen(
onDismiss = { viewingImage = null },
)
}
SessionIntegrationsSheet(
uiState = connectorsState,
sessionId = connectorsState.sessionId,
onDismiss = connectorsViewModel::hide,
onRefresh = { connectorsViewModel.refresh(force = true) },
onConnect = { slug, reconnect -> connectorsViewModel.connect(slug, reconnect) },
onClearError = connectorsViewModel::clearError,
)
}
}
@@ -0,0 +1,752 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorStatus
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState
import com.m57.hermescontrol.ui.chat.ConnectorActionState
import com.m57.hermescontrol.ui.chat.ConnectorUiItem
import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase
/**
* Visual elements for a connector status badge.
*/
data class ConnectorStatusVisuals(
val icon: ImageVector,
val color: Color,
val label: String,
)
@Composable
fun resolveConnectorStatusVisuals(
status: ConnectorStatus,
isConnected: Boolean,
): ConnectorStatusVisuals {
val statusColors = LocalHermesStatusColors.current
return when {
isConnected || status == ConnectorStatus.CONNECTED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.CheckCircle,
color = statusColors.success,
label = stringResource(R.string.session_integrations_status_connected),
)
}
status == ConnectorStatus.INITIATED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.HourglassTop,
color = statusColors.info,
label = stringResource(R.string.session_integrations_awaiting),
)
}
status == ConnectorStatus.EXPIRED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.Warning,
color = statusColors.warning,
label = stringResource(R.string.session_integrations_status_expired),
)
}
status == ConnectorStatus.REVOKED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.Warning,
color = statusColors.warning,
label = stringResource(R.string.session_integrations_status_revoked),
)
}
status == ConnectorStatus.FAILED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.ErrorOutline,
color = statusColors.error,
label = stringResource(R.string.session_integrations_status_failed),
)
}
status == ConnectorStatus.DISCONNECTED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.LinkOff,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
label = stringResource(R.string.session_integrations_status_disconnected),
)
}
else -> {
ConnectorStatusVisuals(
icon = Icons.AutoMirrored.Filled.HelpOutline,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
label = stringResource(R.string.session_integrations_status_unknown),
)
}
}
}
@Composable
fun mapLoadPhaseErrorMessage(loadPhase: ConnectorsLoadPhase): String? =
when (loadPhase) {
is ConnectorsLoadPhase.Error -> {
mapConnectorErrorMessage(loadPhase.error)
}
is ConnectorsLoadPhase.Unavailable -> {
stringResource(R.string.session_integrations_err_unavailable)
}
is ConnectorsLoadPhase.UnsupportedRuntime -> {
stringResource(
R.string.session_integrations_err_unsupported_runtime,
)
}
is ConnectorsLoadPhase.UnsupportedBackend -> {
stringResource(
R.string.session_integrations_err_unsupported_backend,
)
}
is ConnectorsLoadPhase.NotOwner -> {
stringResource(R.string.session_integrations_err_not_owner)
}
else -> {
null
}
}
@Composable
fun mapConnectorErrorMessage(error: ConnectorError): String =
when (error) {
is ConnectorError.InvalidParams -> {
stringResource(R.string.session_integrations_err_invalid_params)
}
is ConnectorError.NotOwner -> {
stringResource(R.string.session_integrations_err_not_owner)
}
is ConnectorError.Unavailable -> {
stringResource(R.string.session_integrations_err_unavailable)
}
is ConnectorError.UnsupportedRuntime -> {
stringResource(R.string.session_integrations_err_unsupported_runtime)
}
is ConnectorError.UnsupportedBackend -> {
stringResource(R.string.session_integrations_err_unsupported_backend)
}
is ConnectorError.RequestFailed -> {
stringResource(R.string.session_integrations_err_request_failed)
}
is ConnectorError.InvalidResponse, is ConnectorError.MalformedEnvelope -> {
stringResource(R.string.session_integrations_err_invalid_response)
}
is ConnectorError.NetworkError -> {
stringResource(R.string.session_integrations_err_network)
}
is ConnectorError.Other -> {
stringResource(R.string.session_integrations_err_generic)
}
}
@Composable
fun mapActionErrorMessage(rawError: String?): String =
when {
rawError.isNullOrBlank() -> {
stringResource(R.string.session_integrations_err_generic)
}
rawError.contains("not owned", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_not_owner)
}
rawError.contains(
"No active session",
ignoreCase = true,
) -> {
stringResource(R.string.session_integrations_no_session)
}
rawError.contains("Invalid connector slug", ignoreCase = true) ||
rawError.contains("Invalid params", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_invalid_params)
}
rawError.contains("authorization URL", ignoreCase = true) ||
rawError.contains("unexpected status", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_invalid_response)
}
rawError.contains("authorization failed", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_request_failed)
}
rawError.contains("browser", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_browser_launch)
}
else -> {
stringResource(R.string.session_integrations_err_generic)
}
}
/**
* Native bottom sheet for session integrations / connectors (issue #1091).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionIntegrationsSheet(
uiState: ChatConnectorsUiState,
sessionId: String? = uiState.sessionId,
onDismiss: () -> Unit,
onRefresh: () -> Unit,
onConnect: (slug: String, reconnect: Boolean) -> Unit,
onClearError: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
if (!uiState.isVisible) return
val effectiveSessionId = sessionId ?: uiState.sessionId
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier.testTag("session_integrations_sheet"),
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
SessionIntegrationsContent(
uiState = uiState,
sessionId = effectiveSessionId,
onDismiss = onDismiss,
onRefresh = onRefresh,
onConnect = onConnect,
onClearError = onClearError,
)
}
}
/**
* Stateless content composable for testing and decoupled layout.
*/
@Composable
fun SessionIntegrationsContent(
uiState: ChatConnectorsUiState,
sessionId: String? = uiState.sessionId,
onDismiss: () -> Unit,
onRefresh: () -> Unit,
onConnect: (slug: String, reconnect: Boolean) -> Unit,
onClearError: () -> Unit,
modifier: Modifier = Modifier,
) {
val statusColors = LocalHermesStatusColors.current
val effectiveSessionId = sessionId ?: uiState.sessionId
val isSessionBlank = effectiveSessionId.isNullOrBlank()
Column(
modifier =
modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
) {
// Top Header
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.session_integrations_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag("session_integrations_title"),
)
Spacer(modifier = Modifier.height(2.dp))
val subtitleText =
if (!isSessionBlank) {
val displayId =
if (effectiveSessionId.length >
12
) {
"${effectiveSessionId.take(12)}…"
} else {
effectiveSessionId
}
stringResource(R.string.session_integrations_subtitle, displayId)
} else {
stringResource(R.string.session_integrations_no_session)
}
Text(
text = subtitleText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("session_integrations_subtitle"),
)
}
IconButton(
onClick = onRefresh,
enabled = !uiState.isLoading && !isSessionBlank,
modifier = Modifier.testTag("session_integrations_refresh_button"),
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.session_integrations_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.testTag("session_integrations_close_button"),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.session_integrations_close),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Action Error Banner
if (uiState.errorMessage != null) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("session_integrations_action_error_banner"),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.ErrorOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = mapActionErrorMessage(uiState.errorMessage),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onClearError,
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.session_integrations_close),
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
}
// Awaiting Authorization Banner (manual Refresh status affordance)
val awaitingSlug =
(uiState.actionState as? ConnectorActionState.AwaitingAuthorization)?.slug
if (awaitingSlug != null) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("session_integrations_awaiting_banner"),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.OpenInBrowser,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.session_integrations_awaiting),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.session_integrations_awaiting_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.height(8.dp))
FilledTonalButton(
onClick = onRefresh,
modifier =
Modifier
.align(Alignment.End)
.testTag("session_integrations_refresh_status_button"),
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.session_integrations_refresh_status),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
// Content States
when {
isSessionBlank || (uiState.loadPhase is ConnectorsLoadPhase.Initial && uiState.items.isEmpty()) -> {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_no_session_state"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
Icon(
imageVector = Icons.Filled.LinkOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.session_integrations_no_session),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
uiState.loadPhase is ConnectorsLoadPhase.Loading -> {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_loading"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.session_integrations_connecting),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
uiState.loadPhase !is ConnectorsLoadPhase.Loading &&
uiState.loadPhase !is ConnectorsLoadPhase.Refreshing &&
uiState.loadPhase !is ConnectorsLoadPhase.Loaded &&
uiState.items.isEmpty() -> {
// Non-loaded error / unavailable / unsupported / not owner terminal states
val errorMessage = mapLoadPhaseErrorMessage(uiState.loadPhase) ?: ""
val canRetry = (uiState.loadPhase as? ConnectorsLoadPhase.Error)?.canRetry ?: false
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_error"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = statusColors.warning,
modifier = Modifier.size(36.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag("session_integrations_error_text"),
)
if (canRetry) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onRefresh,
modifier = Modifier.testTag("session_integrations_retry_button"),
) {
Text(stringResource(R.string.session_integrations_retry))
}
}
}
}
}
uiState.items.isEmpty() -> {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_empty"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.session_integrations_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
else -> {
// List of items
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_integrations_list"),
) {
if (uiState.loadPhase is ConnectorsLoadPhase.Refreshing) {
item {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_integrations_refreshing_indicator"),
)
}
}
items(
items = uiState.items,
key = { it.slug },
) { item ->
ConnectorRow(
item = item,
actionState = uiState.actionState,
isLoading = uiState.isLoading,
onConnect = onConnect,
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
}
}
}
}
}
@Composable
fun ConnectorRow(
item: ConnectorUiItem,
actionState: ConnectorActionState,
isLoading: Boolean,
onConnect: (slug: String, reconnect: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val visuals = resolveConnectorStatusVisuals(item.status, item.isConnected)
val isCurrentConnecting =
actionState is ConnectorActionState.Connecting && actionState.slug == item.slug
val isAnyActionPending =
actionState is ConnectorActionState.Connecting || actionState is ConnectorActionState.AwaitingAuthorization
val needsReconnect =
item.isConnected ||
item.status == ConnectorStatus.EXPIRED ||
item.status == ConnectorStatus.REVOKED
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp)
.testTag("session_integrations_item_${item.slug}"),
verticalAlignment = Alignment.CenterVertically,
) {
// Accessible Status Indicator Icon
Box(
modifier =
Modifier
.size(28.dp)
.clip(CircleShape)
.background(visuals.color.copy(alpha = 0.15f))
.testTag("session_integrations_status_${item.slug}"),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = visuals.icon,
contentDescription = visuals.label,
tint = visuals.color,
modifier = Modifier.size(16.dp),
)
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = visuals.label,
style = MaterialTheme.typography.bodySmall,
color = visuals.color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(12.dp))
if (isCurrentConnecting) {
CircularProgressIndicator(
modifier =
Modifier
.size(20.dp)
.testTag("session_integrations_spinner_${item.slug}"),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
} else {
val buttonText =
if (needsReconnect) {
stringResource(R.string.session_integrations_reconnect)
} else {
stringResource(R.string.session_integrations_connect)
}
OutlinedButton(
onClick = { onConnect(item.slug, needsReconnect) },
enabled = item.isEnabled && !isAnyActionPending && !isLoading,
modifier = Modifier.testTag("session_integrations_action_${item.slug}"),
) {
Text(
text = buttonText,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
@@ -0,0 +1,60 @@
package com.m57.hermescontrol.util
import java.net.URI
/**
* Validates external connector authorization URLs.
*
* Enforces strict security constraints:
* - Must use HTTPS scheme (case-insensitive).
* - Must have a non-empty, valid host without spaces, backslashes, or control characters.
* - Must NOT contain user-info credentials (e.g. `https://user:pass@domain`).
* - Must NOT contain backslashes anywhere in the URI (guard against SSRF/open-redirect bypasses).
* - Port (if specified) must be valid (1..65535).
*/
object ConnectorUrlValidator {
private val HOST_PATTERN = Regex("""^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$""")
fun isValidHttpsUrl(urlString: String?): Boolean {
if (urlString.isNullOrBlank()) return false
// Disallow backslashes, whitespace, and control characters anywhere in the raw URL
for (ch in urlString) {
if (ch == '\\' || ch.isWhitespace() || ch.isISOControl()) {
return false
}
}
return try {
val uri = URI(urlString)
val scheme = uri.scheme ?: return false
if (!scheme.equals("https", ignoreCase = true)) {
return false
}
val host = uri.host
if (host.isNullOrBlank()) {
return false
}
// Userinfo is strictly prohibited (e.g. credentials embedded in URL)
if (uri.rawUserInfo != null) {
return false
}
// Check host structure (must match domain / alphanumeric pattern or valid IP)
if (!HOST_PATTERN.matches(host)) {
return false
}
// Verify port if explicitly provided
if (uri.port != -1 && (uri.port < 1 || uri.port > 65535)) {
return false
}
true
} catch (_: Exception) {
false
}
}
}