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

@@ -0,0 +1,413 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorStatus
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
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class SessionIntegrationsSheetTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testLoadingState_displaysLoadingIndicator() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loading,
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_loading").assertIsDisplayed()
}
@Test
fun testConnectedItem_displaysConnectedStatusAndReconnectAction() {
var clickedSlug: String? = null
var clickedReconnect: Boolean? = null
val item =
ConnectorUiItem(
slug = "github",
name = "GitHub",
isConnected = true,
isEnabled = true,
status = ConnectorStatus.CONNECTED,
connectionStatus = "connected",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = listOf(item),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { slug, reconnect ->
clickedSlug = slug
clickedReconnect = reconnect
},
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed()
composeTestRule.onNodeWithText("GitHub").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_github").performClick()
assertEquals("github", clickedSlug)
assertEquals(true, clickedReconnect)
}
@Test
fun testDisconnectedItem_displaysConnectActionAndTriggersCallback() {
var clickedSlug: String? = null
var clickedReconnect: Boolean? = null
val item =
ConnectorUiItem(
slug = "slack",
name = "Slack",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.DISCONNECTED,
connectionStatus = "disconnected",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = listOf(item),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { slug, reconnect ->
clickedSlug = slug
clickedReconnect = reconnect
},
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_item_slack").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_slack").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_slack").performClick()
assertEquals("slack", clickedSlug)
assertEquals(false, clickedReconnect)
}
@Test
fun testConnectingState_displaysSpinnerAndDisablesActions() {
val item =
ConnectorUiItem(
slug = "google",
name = "Google Drive",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.DISCONNECTED,
connectionStatus = "disconnected",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = listOf(item),
actionState = ConnectorActionState.Connecting(slug = "google", isReconnect = false),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_spinner_google").assertIsDisplayed()
}
@Test
fun testAwaitingAuthorizationState_displaysBannerAndRefreshStatus() {
var refreshed = false
val item =
ConnectorUiItem(
slug = "jira",
name = "Jira",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.INITIATED,
connectionStatus = "initiated",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = listOf(item),
actionState = ConnectorActionState.AwaitingAuthorization(slug = "jira"),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = { refreshed = true },
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_awaiting_banner").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").performClick()
assertTrue(refreshed)
}
@Test
fun testUnavailablePhase_displaysErrorMessage() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Unavailable("Connectors unavailable"),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
}
@Test
fun testErrorPhase_displaysRetryButtonAndTriggersRefresh() {
var retryTriggered = false
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Error(ConnectorError.NetworkError(), canRetry = true),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = { retryTriggered = true },
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_retry_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_retry_button").performClick()
assertTrue(retryTriggered)
}
@Test
fun testEmptyState_displaysNoIntegrationsMessage() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_empty").assertIsDisplayed()
}
@Test
fun testNoSessionState_displaysNoActiveSession() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Initial,
items = emptyList(),
),
sessionId = null,
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_no_session_state").assertIsDisplayed()
}
@Test
fun testNoStorageButRuntimeSession_usesAuthoritativeRuntimeSessionId() {
val item =
ConnectorUiItem(
slug = "linear",
name = "Linear",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.DISCONNECTED,
connectionStatus = "disconnected",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
sessionId = "sess-runtime-999",
items = listOf(item),
),
sessionId = null,
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_item_linear").assertIsDisplayed()
}
@Test
fun testUnsupportedBackend_displaysErrorMessage() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.UnsupportedBackend("Older server does not support connectors"),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
}
@Test
fun testUnsupportedRuntime_displaysErrorMessage() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime("Runtime incompatible"),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
}
@Test
fun testNotOwner_displaysErrorMessage() {
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.NotOwner("Session owned by another user"),
items = emptyList(),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
}
@Test
fun testExpiredAndRevokedItems_displayStatusAndAction() {
val expiredItem =
ConnectorUiItem(
slug = "github",
name = "GitHub",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.EXPIRED,
connectionStatus = "expired",
)
val revokedItem =
ConnectorUiItem(
slug = "jira",
name = "Jira",
isConnected = false,
isEnabled = true,
status = ConnectorStatus.REVOKED,
connectionStatus = "revoked",
)
composeTestRule.setContent {
SessionIntegrationsContent(
uiState =
ChatConnectorsUiState(
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
items = listOf(expiredItem, revokedItem),
),
sessionId = "session-12345",
onDismiss = {},
onRefresh = {},
onConnect = { _, _ -> },
onClearError = {},
)
}
composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_item_jira").assertIsDisplayed()
composeTestRule.onNodeWithTag("session_integrations_action_jira").assertIsDisplayed()
}
}
@@ -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
}
}
}
+32
View File
@@ -1198,4 +1198,36 @@
<string name="group_chat_action_settings">الإعدادات</string>
<string name="group_chat_stopped_by_user">تم إيقاف النقاش</string>
<string name="group_chat_action_stop">إيقاف التوليد</string>
<!-- Session Integrations (issue #1091) -->
<string name="session_integrations_title">التكاملات</string>
<string name="session_integrations_subtitle">الجلسة النشطة: %1$s</string>
<string name="session_integrations_no_session">لا توجد جلسة نشطة</string>
<string name="session_integrations_empty">لا توجد تكاملات متاحة لهذه الجلسة</string>
<string name="session_integrations_refresh">تحديث التكاملات</string>
<string name="session_integrations_connect">ربط</string>
<string name="session_integrations_reconnect">إعادة الربط</string>
<string name="session_integrations_connecting">جاري الاتصال…</string>
<string name="session_integrations_awaiting">بانتظار التفويض</string>
<string name="session_integrations_awaiting_desc">أكمل تسجيل الدخول في المتصفح، ثم ارجع إلى هنا.</string>
<string name="session_integrations_refresh_status">تحديث الحالة</string>
<string name="session_integrations_status_connected">متصل</string>
<string name="session_integrations_status_disconnected">غير متصل</string>
<string name="session_integrations_status_expired">منتهي الصلاحية</string>
<string name="session_integrations_status_revoked">تم إلغاء الوصول</string>
<string name="session_integrations_status_failed">فشل</string>
<string name="session_integrations_status_unknown">غير معروف</string>
<string name="session_integrations_err_invalid_params">معلمات التكامل غير صالحة</string>
<string name="session_integrations_err_not_owner">الجلسة غير مملوكة بواسطة مسار النقل هذا. يرجى إعادة الاتصال بالجلسة.</string>
<string name="session_integrations_err_unavailable">التكاملات غير متوفرة في هذه الجلسة</string>
<string name="session_integrations_err_unsupported_runtime">يجب إدارة التكاملات على جهاز مضيف الجلسة</string>
<string name="session_integrations_err_unsupported_backend">التكاملات غير مدعومة من هذا الخادم</string>
<string name="session_integrations_err_request_failed">فشل طلب التكامل. يرجى المحاولة مرة أخرى.</string>
<string name="session_integrations_err_invalid_response">استجابة غير صالحة من خدمة التكامل</string>
<string name="session_integrations_err_network">تعذر الاتصال بالبوابة</string>
<string name="session_integrations_err_browser_launch">تعذر فتح المتصفح للتفويض</string>
<string name="session_integrations_err_generic">فشلت عملية التكامل</string>
<string name="session_integrations_retry">إعادة المحاولة</string>
<string name="session_integrations_close">إغلاق</string>
<string name="session_integrations_menu_item">تكاملات الجلسة</string>
</resources>
+32
View File
@@ -888,4 +888,36 @@
<string name="chat_fast_mode_label">빠름</string>
<string name="chat_fast_mode_desc">빠른 모드</string>
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
<!-- Session Integrations (issue #1091) -->
<string name="session_integrations_title">연동</string>
<string name="session_integrations_subtitle">활성 세션: %1$s</string>
<string name="session_integrations_no_session">활성 세션 없음</string>
<string name="session_integrations_empty">이 세션에서 사용할 수 있는 연동이 없습니다</string>
<string name="session_integrations_refresh">연동 새로고침</string>
<string name="session_integrations_connect">연결</string>
<string name="session_integrations_reconnect">다시 연결</string>
<string name="session_integrations_connecting">연결 중…</string>
<string name="session_integrations_awaiting">인증 대기 중</string>
<string name="session_integrations_awaiting_desc">브라우저에서 로그인을 완료한 후 다시 돌아오세요.</string>
<string name="session_integrations_refresh_status">상태 새로고침</string>
<string name="session_integrations_status_connected">연결됨</string>
<string name="session_integrations_status_disconnected">연결 해제됨</string>
<string name="session_integrations_status_expired">만료됨</string>
<string name="session_integrations_status_revoked">취소됨</string>
<string name="session_integrations_status_failed">실패</string>
<string name="session_integrations_status_unknown">알 수 없음</string>
<string name="session_integrations_err_invalid_params">잘못된 커넥터 매개변수</string>
<string name="session_integrations_err_not_owner">이 전송 계층이 세션을 소유하고 있지 않습니다. 세션을 다시 연결하세요.</string>
<string name="session_integrations_err_unavailable">이 세션에서는 연동을 사용할 수 없습니다</string>
<string name="session_integrations_err_unsupported_runtime">연동은 세션 호스트에서 관리해야 합니다</string>
<string name="session_integrations_err_unsupported_backend">백엔드에서 연동 서비스를 지원하지 않습니다</string>
<string name="session_integrations_err_request_failed">커넥터 요청에 실패했습니다. 다시 시도해 주세요.</string>
<string name="session_integrations_err_invalid_response">커넥터 서비스에서 잘못된 응답을 반환했습니다</string>
<string name="session_integrations_err_network">게이트웨이와의 통신에 실패했습니다</string>
<string name="session_integrations_err_browser_launch">인증을 위한 브라우저를 열 수 없습니다</string>
<string name="session_integrations_err_generic">커넥터 작업에 실패했습니다</string>
<string name="session_integrations_retry">다시 시도</string>
<string name="session_integrations_close">닫기</string>
<string name="session_integrations_menu_item">세션 연동</string>
</resources>
+32
View File
@@ -1175,4 +1175,36 @@
<string name="group_chat_settings_high_limits_confirm">仍要保存</string>
<string name="group_chat_settings_high_limits_adjust">调整限制</string>
<string name="group_chat_action_settings">设置</string>
<!-- Session Integrations (issue #1091) -->
<string name="session_integrations_title">集成</string>
<string name="session_integrations_subtitle">当前会话:%1$s</string>
<string name="session_integrations_no_session">无活动会话</string>
<string name="session_integrations_empty">此会话暂无可用集成</string>
<string name="session_integrations_refresh">刷新集成</string>
<string name="session_integrations_connect">连接</string>
<string name="session_integrations_reconnect">重新连接</string>
<string name="session_integrations_connecting">正在连接…</string>
<string name="session_integrations_awaiting">等待授权</string>
<string name="session_integrations_awaiting_desc">请在浏览器中完成登录,然后返回此处。</string>
<string name="session_integrations_refresh_status">刷新状态</string>
<string name="session_integrations_status_connected">已连接</string>
<string name="session_integrations_status_disconnected">未连接</string>
<string name="session_integrations_status_expired">已过期</string>
<string name="session_integrations_status_revoked">已撤销</string>
<string name="session_integrations_status_failed">失败</string>
<string name="session_integrations_status_unknown">未知</string>
<string name="session_integrations_err_invalid_params">连接器参数无效</string>
<string name="session_integrations_err_not_owner">此传输通道未拥有该会话。请重新连接会话。</string>
<string name="session_integrations_err_unavailable">此会话中不可使用集成</string>
<string name="session_integrations_err_unsupported_runtime">集成必须在会话所在的主机上进行管理</string>
<string name="session_integrations_err_unsupported_backend">后端不支持集成服务</string>
<string name="session_integrations_err_request_failed">连接器请求失败,请重试。</string>
<string name="session_integrations_err_invalid_response">连接器服务返回无效响应</string>
<string name="session_integrations_err_network">与网关通信失败</string>
<string name="session_integrations_err_browser_launch">无法打开浏览器以进行授权</string>
<string name="session_integrations_err_generic">连接器操作失败</string>
<string name="session_integrations_retry">重试</string>
<string name="session_integrations_close">关闭</string>
<string name="session_integrations_menu_item">会话集成</string>
</resources>
+32
View File
@@ -1387,4 +1387,36 @@
<string name="vault_code_label">Verification Code</string>
<string name="vault_code_action">Submit</string>
<string name="vault_code_dismiss">Skip</string>
<!-- Session Integrations (issue #1091) -->
<string name="session_integrations_title">Integrations</string>
<string name="session_integrations_subtitle">Active Session: %1$s</string>
<string name="session_integrations_no_session">No active session</string>
<string name="session_integrations_empty">No integrations available for this session</string>
<string name="session_integrations_refresh">Refresh integrations</string>
<string name="session_integrations_connect">Connect</string>
<string name="session_integrations_reconnect">Reconnect</string>
<string name="session_integrations_connecting">Connecting…</string>
<string name="session_integrations_awaiting">Awaiting authorization</string>
<string name="session_integrations_awaiting_desc">Complete sign-in in your browser, then return here.</string>
<string name="session_integrations_refresh_status">Refresh status</string>
<string name="session_integrations_status_connected">Connected</string>
<string name="session_integrations_status_disconnected">Disconnected</string>
<string name="session_integrations_status_expired">Expired</string>
<string name="session_integrations_status_revoked">Revoked</string>
<string name="session_integrations_status_failed">Failed</string>
<string name="session_integrations_status_unknown">Unknown</string>
<string name="session_integrations_err_invalid_params">Invalid connector parameters</string>
<string name="session_integrations_err_not_owner">Session is not owned by this transport. Please reconnect the session.</string>
<string name="session_integrations_err_unavailable">Integrations are not available in this session</string>
<string name="session_integrations_err_unsupported_runtime">Integrations must be managed on the session compute host</string>
<string name="session_integrations_err_unsupported_backend">Integrations are not supported by this backend</string>
<string name="session_integrations_err_request_failed">Connector request failed. Please try again.</string>
<string name="session_integrations_err_invalid_response">Invalid response from connector service</string>
<string name="session_integrations_err_network">Failed to communicate with gateway</string>
<string name="session_integrations_err_browser_launch">Could not open browser for authorization</string>
<string name="session_integrations_err_generic">Connector operation failed</string>
<string name="session_integrations_retry">Retry</string>
<string name="session_integrations_close">Close</string>
<string name="session_integrations_menu_item">Session Integrations</string>
</resources>
@@ -0,0 +1,742 @@
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.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.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectorParserTest {
@Test
fun `parseListResult parses standard valid envelope`() {
val payload =
mapOf(
"available" to true,
"connectors" to
listOf(
mapOf(
"connector" to "linear",
"name" to "Linear",
"connected" to true,
"enabled" to true,
"connectionStatus" to "active",
),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertTrue(success.available)
assertEquals(1, success.connectors.size)
val item = success.connectors[0]
assertEquals("linear", item.connector)
assertEquals("Linear", item.name)
assertTrue(item.connected)
assertTrue(item.enabled)
assertEquals(ConnectorStatus.CONNECTED, item.status)
assertTrue(item.isConnected)
}
@Test
fun `parseListResult handles unavailable state`() {
val payload =
mapOf(
"available" to false,
"connectors" to emptyList<Map<String, Any>>(),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertFalse(success.available)
assertTrue(success.connectors.isEmpty())
}
@Test
fun `parseListResult handles empty connectors list`() {
val payload =
mapOf(
"available" to true,
"connectors" to emptyList<Map<String, Any>>(),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertTrue(success.available)
assertTrue(success.connectors.isEmpty())
}
@Test
fun `parseListResult deliberate boolean connected precedence over stale backend statuses`() {
// When connected is true, status is ALWAYS CONNECTED even if backend reports expired or revoked
val payloadExpired =
mapOf(
"connectors" to
listOf(
mapOf("connector" to "c1", "connected" to true, "connectionStatus" to "expired"),
mapOf("connector" to "c2", "connected" to true, "connectionStatus" to "revoked"),
),
)
val resultExpired = ConnectorParser.parseListResult(payloadExpired) as ConnectorListResult.Success
assertEquals(ConnectorStatus.CONNECTED, resultExpired.connectors[0].status)
assertEquals(ConnectorStatus.CONNECTED, resultExpired.connectors[1].status)
// When connected is false, status reflects expired or revoked, and stale "active" resolves to DISCONNECTED
val payloadFalse =
mapOf(
"connectors" to
listOf(
mapOf("connector" to "c3", "connected" to false, "connectionStatus" to "expired"),
mapOf("connector" to "c4", "connected" to false, "connectionStatus" to "revoked"),
mapOf("connector" to "c5", "connected" to false, "connectionStatus" to "active"),
mapOf("connector" to "c6", "connected" to false, "connectionStatus" to "initiated"),
mapOf("connector" to "c7", "connected" to false, "connectionStatus" to "error"),
),
)
val resultFalse = ConnectorParser.parseListResult(payloadFalse) as ConnectorListResult.Success
assertEquals(ConnectorStatus.EXPIRED, resultFalse.connectors[0].status)
assertEquals(ConnectorStatus.REVOKED, resultFalse.connectors[1].status)
assertEquals(ConnectorStatus.DISCONNECTED, resultFalse.connectors[2].status)
assertEquals(ConnectorStatus.INITIATED, resultFalse.connectors[3].status)
assertEquals(ConnectorStatus.FAILED, resultFalse.connectors[4].status)
}
@Test
fun `parseListResult supports legacy aliases`() {
val payload =
mapOf(
"connectors" to
listOf(
mapOf(
"slug" to "github",
"displayName" to "GitHub App",
"status" to "expired",
"auth_url" to "https://github.com/login/oauth/authorize",
),
mapOf(
"id" to "jira",
"title" to "Jira Cloud",
"connection_status" to "revoked",
"connectUrl" to "https://auth.atlassian.com/oauth",
),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertEquals(2, success.connectors.size)
val c1 = success.connectors[0]
assertEquals("github", c1.connector)
assertEquals("GitHub App", c1.name)
assertEquals(ConnectorStatus.EXPIRED, c1.status)
assertEquals("https://github.com/login/oauth/authorize", c1.safeConnectUrl)
val c2 = success.connectors[1]
assertEquals("jira", c2.connector)
assertEquals("Jira Cloud", c2.name)
assertEquals(ConnectorStatus.REVOKED, c2.status)
assertEquals("https://auth.atlassian.com/oauth", c2.safeConnectUrl)
}
@Test
fun `parseListResult safely handles wrong optional field types without crash`() {
val payload =
mapOf(
"available" to "true",
"connectors" to
listOf(
mapOf(
"connector" to "slack",
"name" to 12345, // Number instead of string
"connected" to "true", // String instead of boolean
"enabled" to "false", // String instead of boolean
),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
val item = success.connectors[0]
assertEquals("slack", item.connector)
assertEquals("12345", item.name)
assertTrue(item.connected)
assertFalse(item.enabled)
}
@Test
fun `parseListResult maps unknown status to UNKNOWN`() {
val payload =
mapOf(
"connectors" to
listOf(
mapOf(
"connector" to "notion",
"connected" to false,
"connectionStatus" to "pending_approval_future_state",
),
),
)
val result = ConnectorParser.parseListResult(payload) as ConnectorListResult.Success
assertEquals(ConnectorStatus.UNKNOWN, result.connectors[0].status)
}
@Test
fun `parseListResult fails on malformed required envelopes without fabricating empty success`() {
// Missing "connectors" key
val missingConnectors = mapOf("available" to true)
val r1 = ConnectorParser.parseListResult(missingConnectors)
assertTrue(r1 is ConnectorListResult.Error)
assertTrue((r1 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
// "connectors" is a string instead of array
val stringConnectors = mapOf("connectors" to "invalid")
val r2 = ConnectorParser.parseListResult(stringConnectors)
assertTrue(r2 is ConnectorListResult.Error)
assertTrue((r2 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
// Raw array at root instead of object
val rawList = listOf("c1", "c2")
val r3 = ConnectorParser.parseListResult(rawList)
assertTrue(r3 is ConnectorListResult.Error)
assertTrue((r3 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
// Primitive string at root
val r4 = ConnectorParser.parseListResult("some random string")
assertTrue(r4 is ConnectorListResult.Error)
assertTrue((r4 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
// Null root
val r5 = ConnectorParser.parseListResult(null)
assertTrue(r5 is ConnectorListResult.Error)
assertTrue((r5 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
}
@Test
fun `parseConnectResult parses standard connect initiated response`() {
val payload =
mapOf(
"results" to
listOf(
mapOf(
"connector" to "linear",
"status" to "initiated",
"connect_url" to "https://linear.app/oauth/authorize?client_id=123",
"instruction" to "Arbitrary backend instruction that must not be exposed as UI text",
),
),
"summary" to
mapOf(
"total" to 1,
"active" to 0,
"initiated" to 1,
"failed" to 0,
),
)
val result = ConnectorParser.parseConnectResult(payload)
assertTrue(result is ConnectorConnectResult.Success)
val success = result as ConnectorConnectResult.Success
assertEquals(1, success.results.size)
val item = success.results[0]
assertEquals("linear", item.connector)
assertEquals(ConnectorConnectStatus.INITIATED, item.status)
assertTrue(item.isConnectUrlValid)
assertEquals("https://linear.app/oauth/authorize?client_id=123", item.safeConnectUrl)
assertEquals(1, success.summary.total)
assertEquals(1, success.summary.initiated)
assertEquals(0, success.summary.active)
}
@Test
fun `parseConnectResult parses connect already active response`() {
val payload =
mapOf(
"results" to
listOf(
mapOf(
"connector" to "linear",
"status" to "active",
),
),
"summary" to
mapOf(
"total" to 1,
"active" to 1,
"initiated" to 0,
"failed" to 0,
),
)
val result = ConnectorParser.parseConnectResult(payload)
assertTrue(result is ConnectorConnectResult.Success)
val success = result as ConnectorConnectResult.Success
assertEquals(1, success.results.size)
val item = success.results[0]
assertEquals("linear", item.connector)
assertEquals(ConnectorConnectStatus.ACTIVE, item.status)
assertTrue(item.status.isActive)
assertNull(item.safeConnectUrl)
}
@Test
fun `parseConnectResult supports legacy aliases and unknown status`() {
val payload =
mapOf(
"results" to
listOf(
mapOf(
"slug" to "github",
"connectionStatus" to "failed",
"auth_url" to "https://github.com/login",
),
mapOf(
"connector" to "notion",
"status" to "custom_unknown_status",
),
),
"summary" to
mapOf(
"total" to "2", // String count coerced safely
"failed" to "1",
),
)
val result = ConnectorParser.parseConnectResult(payload) as ConnectorConnectResult.Success
assertEquals(2, result.results.size)
assertEquals("github", result.results[0].connector)
assertEquals(ConnectorConnectStatus.FAILED, result.results[0].status)
assertEquals("https://github.com/login", result.results[0].safeConnectUrl)
assertEquals("notion", result.results[1].connector)
assertEquals(ConnectorConnectStatus.UNKNOWN, result.results[1].status)
assertEquals(2, result.summary.total)
assertEquals(1, result.summary.failed)
assertEquals(0, result.summary.active)
}
@Test
fun `parseConnectResult rejects insecure or invalid auth URLs`() {
val payload =
mapOf(
"results" to
listOf(
mapOf(
"connector" to "insecure_http",
"status" to "initiated",
"connect_url" to "http://example.com/oauth",
),
mapOf(
"connector" to "js_proto",
"status" to "initiated",
"connect_url" to "javascript:alert(1)",
),
mapOf(
"connector" to "user_creds",
"status" to "initiated",
"connect_url" to "https://user:pass@evil.com/",
),
),
"summary" to
mapOf(
"total" to 3,
"initiated" to 3,
),
)
val result = ConnectorParser.parseConnectResult(payload) as ConnectorConnectResult.Success
assertEquals(3, result.results.size)
for (item in result.results) {
assertFalse(item.isConnectUrlValid)
assertNull(item.safeConnectUrl)
}
}
@Test
fun `parseConnectResult fails on malformed required envelope without fabricating success`() {
// Missing results
val r1 = ConnectorParser.parseConnectResult(mapOf("summary" to mapOf("total" to 0)))
assertTrue(r1 is ConnectorConnectResult.Error)
assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
// Results is a string
val r2 = ConnectorParser.parseConnectResult(mapOf("results" to "not a list"))
assertTrue(r2 is ConnectorConnectResult.Error)
assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
// Primitive string
val r3 = ConnectorParser.parseConnectResult("random text")
assertTrue(r3 is ConnectorConnectResult.Error)
}
@Test
fun `toString redacts auth URLs on ConnectorItem and ConnectorConnectItem`() {
val sensitiveUrl = "https://example.com/oauth?client_secret=super_secret_token_12345"
val item =
ConnectorItem(
connector = "linear",
name = "Linear",
connectUrl = sensitiveUrl,
)
assertFalse(item.toString().contains("super_secret_token_12345"))
assertTrue(item.toString().contains("[REDACTED]"))
val connectItem =
ConnectorConnectItem(
connector = "linear",
status = ConnectorConnectStatus.INITIATED,
connectUrl = sensitiveUrl,
)
assertFalse(connectItem.toString().contains("super_secret_token_12345"))
assertTrue(connectItem.toString().contains("[REDACTED]"))
}
@Test
fun `mapRpcError correctly maps backend codes and reasons`() {
// 4000 INVALID_PARAMS
val e4000 =
ConnectorParser.mapRpcError(
code = 4000,
message = "session_id required",
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_PARAMS"))),
)
assertTrue(e4000 is ConnectorError.InvalidParams)
// 4001 NOT_OWNER
val e4001 =
ConnectorParser.mapRpcError(
code = 4001,
message = "session not owned",
data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))),
)
assertTrue(e4001 is ConnectorError.NotOwner)
// 4031 CONNECTORS_UNAVAILABLE
val e4031 =
ConnectorParser.mapRpcError(
code = 4031,
message = "connectors unavailable",
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))),
)
assertTrue(e4031 is ConnectorError.Unavailable)
// 5033 UNSUPPORTED_RUNTIME
val e5033 =
ConnectorParser.mapRpcError(
code = 5033,
message = "compute host required",
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
)
assertTrue(e5033 is ConnectorError.UnsupportedRuntime)
// 5034 INVALID_CONNECTOR_RESPONSE
val e5034a =
ConnectorParser.mapRpcError(
code = 5034,
message = "invalid response",
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))),
)
assertTrue(e5034a is ConnectorError.InvalidResponse)
// 5034 CONNECTOR_REQUEST_FAILED
val e5034b =
ConnectorParser.mapRpcError(
code = 5034,
message = "request failed",
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTOR_REQUEST_FAILED"))),
)
assertTrue(e5034b is ConnectorError.RequestFailed)
// -32601 Method not found
val e32601 =
ConnectorParser.mapRpcError(
code = -32601,
message = "Method not found",
data = null,
)
assertTrue(e32601 is ConnectorError.UnsupportedBackend)
// Other code
val eOther =
ConnectorParser.mapRpcError(
code = 9999,
message = "Custom failure",
data = null,
)
assertTrue(eOther is ConnectorError.Other)
assertEquals(9999, (eOther as ConnectorError.Other).code)
}
@Test
fun `mapRpcError never leaks raw backend messages or secrets into typed error or toString`() {
val secretApiKey = "sk-live-secret-super-sensitive-api-key-987654321"
val secretDbPass = "postgres://user:super_secret_db_pass@10.0.0.1:5432/db"
// Known error code with secret in message
val e4000 =
ConnectorParser.mapRpcError(
code = 4000,
message = "Invalid auth header: $secretApiKey",
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_PARAMS"))),
)
assertFalse("Error message must not leak secret", e4000.message.contains(secretApiKey))
assertFalse("Error toString must not leak secret", e4000.toString().contains(secretApiKey))
assertEquals("Invalid connector parameters.", e4000.message)
// Unknown error code with secret in message
val eUnknown =
ConnectorParser.mapRpcError(
code = 8888,
message = "Internal connection failed to $secretDbPass",
data = null,
)
assertFalse("Unknown error message must not leak secret", eUnknown.message.contains(secretDbPass))
assertFalse("Unknown error toString must not leak secret", eUnknown.toString().contains(secretDbPass))
assertEquals("Connector operation failed.", eUnknown.message)
}
@Test
fun `parseConnectorItem strictly rejects non-string identifiers and invalid slug formats`() {
val payload =
mapOf(
"connectors" to
listOf(
mapOf("connector" to true, "name" to "Boolean Slug"),
mapOf("id" to 12345, "name" to "Number ID"),
mapOf("slug" to "INVALID UPPERCASE", "name" to "Uppercase"),
mapOf("connector" to "-leading-hyphen", "name" to "Leading Hyphen"),
mapOf("connector" to "slug with spaces", "name" to "Spaces"),
mapOf("connector" to "valid_slug-1", "name" to "Valid Item"),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertEquals(1, success.connectors.size)
assertEquals("valid_slug-1", success.connectors[0].connector)
assertEquals("Valid Item", success.connectors[0].name)
}
@Test
fun `parseListResult deduplicates items by slug to prevent duplicate keys in lazy lists`() {
val payload =
mapOf(
"connectors" to
listOf(
mapOf("connector" to "github", "name" to "First GitHub"),
mapOf("connector" to "github", "name" to "Duplicate GitHub"),
mapOf("connector" to "slack", "name" to "Slack"),
mapOf("connector" to "slack", "name" to "Duplicate Slack"),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertEquals(2, success.connectors.size)
assertEquals("github", success.connectors[0].connector)
assertEquals("First GitHub", success.connectors[0].name)
assertEquals("slack", success.connectors[1].connector)
assertEquals("Slack", success.connectors[1].name)
}
@Test
fun `parseListResult fails with MalformedEnvelope when nonempty connectors array contains only invalid items`() {
val payload =
mapOf(
"connectors" to
listOf(
"not an object",
12345,
mapOf("connector" to true),
mapOf("slug" to "INVALID SLUG"),
mapOf("bogus" to "no identifier"),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Error)
assertTrue((result as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope)
}
@Test
fun `parseListResult tolerates mixed junk items when valid items exist`() {
val payload =
mapOf(
"connectors" to
listOf(
"random junk string",
mapOf("connector" to "linear", "name" to "Linear"),
12345,
mapOf("connector" to true),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertEquals(1, success.connectors.size)
assertEquals("linear", success.connectors[0].connector)
}
@Test
fun `parseConnectResult fails when results is empty or all items invalid`() {
// Empty results array
val payloadEmpty =
mapOf(
"results" to emptyList<Map<String, Any>>(),
"summary" to mapOf("total" to 0),
)
val rEmpty = ConnectorParser.parseConnectResult(payloadEmpty)
assertTrue(rEmpty is ConnectorConnectResult.Error)
assertTrue((rEmpty as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse)
// All invalid items
val payloadInvalid =
mapOf(
"results" to
listOf(
mapOf("connector" to true),
mapOf("id" to 12345),
mapOf("connector" to "INVALID UPPER"),
),
"summary" to mapOf("total" to 3),
)
val rInvalid = ConnectorParser.parseConnectResult(payloadInvalid)
assertTrue(rInvalid is ConnectorConnectResult.Error)
assertTrue((rInvalid as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse)
}
@Test
fun `parseConnectResult fails when summary object is missing or non-object in standard envelope`() {
// Missing summary
val payloadMissingSummary =
mapOf(
"results" to
listOf(
mapOf("connector" to "linear", "status" to "active"),
),
)
val r1 = ConnectorParser.parseConnectResult(payloadMissingSummary)
assertTrue(r1 is ConnectorConnectResult.Error)
assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
// Non-object summary (string)
val payloadStringSummary =
mapOf(
"results" to
listOf(
mapOf("connector" to "linear", "status" to "active"),
),
"summary" to "invalid_string_summary",
)
val r2 = ConnectorParser.parseConnectResult(payloadStringSummary)
assertTrue(r2 is ConnectorConnectResult.Error)
assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope)
}
@Test
fun `parseConnectResult tolerates purposeful legacy bare array form and derives summary`() {
val legacyPayload =
listOf(
mapOf("connector" to "linear", "status" to "active"),
mapOf("connector" to "github", "status" to "initiated", "connect_url" to "https://github.com/login"),
)
val result = ConnectorParser.parseConnectResult(legacyPayload)
assertTrue(result is ConnectorConnectResult.Success)
val success = result as ConnectorConnectResult.Success
assertEquals(2, success.results.size)
assertEquals("linear", success.results[0].connector)
assertEquals(ConnectorConnectStatus.ACTIVE, success.results[0].status)
assertEquals("github", success.results[1].connector)
assertEquals(ConnectorConnectStatus.INITIATED, success.results[1].status)
// Summary should be synthesized
assertEquals(2, success.summary.total)
assertEquals(1, success.summary.active)
assertEquals(1, success.summary.initiated)
assertEquals(0, success.summary.failed)
}
@Test
fun `legacy connected consistency - status authorized yields effective connected when boolean is missing`() {
val payload =
mapOf(
"connectors" to
listOf(
// Missing "connected" boolean, status "authorized"
mapOf("connector" to "c1", "connectionStatus" to "authorized"),
// Missing "connected" boolean, status "active"
mapOf("connector" to "c2", "connectionStatus" to "active"),
// Missing "connected" boolean, status "connected"
mapOf("connector" to "c3", "connectionStatus" to "connected"),
// Missing "connected" boolean, status "expired"
mapOf("connector" to "c4", "connectionStatus" to "expired"),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
assertEquals(4, success.connectors.size)
val c1 = success.connectors[0]
assertEquals(ConnectorStatus.CONNECTED, c1.status)
assertTrue(c1.connected)
assertTrue(c1.isConnected)
val c2 = success.connectors[1]
assertEquals(ConnectorStatus.CONNECTED, c2.status)
assertTrue(c2.connected)
assertTrue(c2.isConnected)
val c3 = success.connectors[2]
assertEquals(ConnectorStatus.CONNECTED, c3.status)
assertTrue(c3.connected)
assertTrue(c3.isConnected)
val c4 = success.connectors[3]
assertEquals(ConnectorStatus.EXPIRED, c4.status)
assertFalse(c4.connected)
assertFalse(c4.isConnected)
}
@Test
fun `explicit connected false is authoritative and not overridden by status active or authorized`() {
val payload =
mapOf(
"connectors" to
listOf(
mapOf("connector" to "c1", "connected" to false, "connectionStatus" to "active"),
mapOf("connector" to "c2", "connected" to false, "connectionStatus" to "authorized"),
mapOf("connector" to "c3", "connected" to false, "connectionStatus" to "connected"),
),
)
val result = ConnectorParser.parseListResult(payload)
assertTrue(result is ConnectorListResult.Success)
val success = result as ConnectorListResult.Success
for (item in success.connectors) {
assertFalse(item.connected)
assertFalse(item.isConnected)
assertEquals(ConnectorStatus.DISCONNECTED, item.status)
}
}
}
@@ -0,0 +1,376 @@
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 io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class ConnectorRepositoryTest {
@Test
fun `exact method constants match upstream gateway expectations`() {
assertEquals("connectors.list", WsMethods.CONNECTORS_LIST)
assertEquals("connectors.connect", WsMethods.CONNECTORS_CONNECT)
}
@Test
fun `listConnectors sends exact parameters without extra profile injection`() =
runBlocking {
var recordedMethod: String? = null
var recordedParams: Map<String, Any>? = null
val repo =
HermesConnectorRepository(
rpcRequest = { method, params ->
recordedMethod = method
recordedParams = params
mapOf("available" to true, "connectors" to emptyList<Map<String, Any>>())
},
)
val result = repo.listConnectors("sess-abc-123")
assertTrue(result is ConnectorListResult.Success)
assertEquals(WsMethods.CONNECTORS_LIST, recordedMethod)
assertEquals(mapOf("session_id" to "sess-abc-123"), recordedParams)
// Verify no profile or singular slug mutations
assertFalse(recordedParams?.containsKey("profile") == true)
assertFalse(recordedParams?.containsKey("connector") == true)
}
@Test
fun `connect sends exact parameters with connectors array and reconnect boolean`() =
runBlocking {
var recordedMethod: String? = null
var recordedParams: Map<String, Any>? = null
val repo =
HermesConnectorRepository(
rpcRequest = { method, params ->
recordedMethod = method
recordedParams = params
mapOf(
"results" to
listOf(
mapOf("connector" to "linear", "status" to "initiated"),
),
"summary" to mapOf("total" to 1, "initiated" to 1),
)
},
)
val result =
repo.connect(
sessionId = "sess-456",
connectors = listOf("linear", "github_app-1"),
reconnect = true,
)
assertTrue(result is ConnectorConnectResult.Success)
assertEquals(WsMethods.CONNECTORS_CONNECT, recordedMethod)
assertEquals("sess-456", recordedParams?.get("session_id"))
assertEquals(listOf("linear", "github_app-1"), recordedParams?.get("connectors"))
assertEquals(true, recordedParams?.get("reconnect"))
// Verify no singular slug key mutation
assertFalse(recordedParams?.containsKey("connector") == true)
}
@Test
fun `connect preserves reconnect false default`() =
runBlocking {
var recordedParams: Map<String, Any>? = null
val repo =
HermesConnectorRepository(
rpcRequest = { _, params ->
recordedParams = params
mapOf(
"results" to
listOf(
mapOf("connector" to "slack", "status" to "active"),
),
"summary" to mapOf("total" to 1, "active" to 1),
)
},
)
repo.connect(sessionId = "sess-789", connectors = listOf("slack"))
assertEquals(false, recordedParams?.get("reconnect"))
}
@Test
fun `listConnectors validates non-blank session_id before network`() =
runBlocking {
var rpcCalled = false
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
rpcCalled = true
null
},
)
val r1 = repo.listConnectors("")
val r2 = repo.listConnectors(" ")
assertFalse(rpcCalled)
assertTrue(r1 is ConnectorListResult.Error)
assertTrue((r1 as ConnectorListResult.Error).error is ConnectorError.InvalidParams)
assertTrue(r2 is ConnectorListResult.Error)
assertTrue((r2 as ConnectorListResult.Error).error is ConnectorError.InvalidParams)
}
@Test
fun `connect validates session_id and connectors list before network`() =
runBlocking {
var rpcCalled = false
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
rpcCalled = true
null
},
)
// Blank session_id
val r1 = repo.connect(sessionId = "", connectors = listOf("linear"))
assertFalse(rpcCalled)
assertTrue(r1 is ConnectorConnectResult.Error)
assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams)
// Empty connectors
val r2 = repo.connect(sessionId = "sess-1", connectors = emptyList())
assertFalse(rpcCalled)
assertTrue(r2 is ConnectorConnectResult.Error)
assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams)
}
@Test
fun `connect validates slug pattern matches backend expectations`() =
runBlocking {
var rpcCalled = false
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
rpcCalled = true
null
},
)
// Invalid slugs: uppercase, starting with hyphen/underscore, containing spaces/symbols
val invalidSlugs =
listOf(
"Linear", // uppercase
"-linear", // leading hyphen
"_linear", // leading underscore
"linear app", // space
"linear/app", // slash
"linear@app", // symbol
"", // empty
)
for (invalidSlug in invalidSlugs) {
rpcCalled = false
val result = repo.connect(sessionId = "sess-1", connectors = listOf(invalidSlug))
assertFalse("Expected RPC not to be called for invalid slug '$invalidSlug'", rpcCalled)
assertTrue("Expected InvalidParams error for '$invalidSlug'", result is ConnectorConnectResult.Error)
assertTrue((result as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams)
}
// Valid slugs
assertTrue(ConnectorRepository.isValidSlug("gmail"))
assertTrue(ConnectorRepository.isValidSlug("google_drive"))
assertTrue(ConnectorRepository.isValidSlug("jira-service-desk"))
assertTrue(ConnectorRepository.isValidSlug("0auth_provider"))
}
@Test
fun `cancellation exception rethrows and is not swallowed`() =
runBlocking {
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw CancellationException("coroutine cancelled")
},
)
try {
repo.listConnectors("sess-1")
fail("Expected CancellationException to be rethrown on listConnectors")
} catch (e: CancellationException) {
assertEquals("coroutine cancelled", e.message)
}
try {
repo.connect("sess-1", listOf("linear"))
fail("Expected CancellationException to be rethrown on connect")
} catch (e: CancellationException) {
assertEquals("coroutine cancelled", e.message)
}
}
@Test
fun `maps HermesRpcException to typed errors`() =
runBlocking {
// 4031 CONNECTORS_UNAVAILABLE
val repo4031 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Connectors unavailable",
code = 4031,
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))),
)
},
)
val r4031 = repo4031.listConnectors("sess-1")
assertTrue(r4031 is ConnectorListResult.Error)
assertTrue((r4031 as ConnectorListResult.Error).error is ConnectorError.Unavailable)
// 4001 NOT_OWNER
val repo4001 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Session not owned",
code = 4001,
data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))),
)
},
)
val r4001 = repo4001.connect("sess-1", listOf("linear"))
assertTrue(r4001 is ConnectorConnectResult.Error)
assertTrue((r4001 as ConnectorConnectResult.Error).error is ConnectorError.NotOwner)
// 5033 UNSUPPORTED_RUNTIME
val repo5033 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Compute host required",
code = 5033,
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
)
},
)
val r5033 = repo5033.connect("sess-1", listOf("linear"))
assertTrue(r5033 is ConnectorConnectResult.Error)
assertTrue((r5033 as ConnectorConnectResult.Error).error is ConnectorError.UnsupportedRuntime)
// 5034 INVALID_CONNECTOR_RESPONSE
val repo5034 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Invalid connector response",
code = 5034,
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))),
)
},
)
val r5034 = repo5034.connect("sess-1", listOf("linear"))
assertTrue(r5034 is ConnectorConnectResult.Error)
assertTrue((r5034 as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse)
// -32601 Unsupported backend
val repo32601 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Method not found",
code = -32601,
data = null,
)
},
)
val r32601 = repo32601.listConnectors("sess-1")
assertTrue(r32601 is ConnectorListResult.Error)
assertTrue((r32601 as ConnectorListResult.Error).error is ConnectorError.UnsupportedBackend)
}
@Test
fun `generic network exception maps to typed NetworkError`() =
runBlocking {
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw java.io.IOException("Connection reset by peer")
},
)
val rList = repo.listConnectors("sess-1")
assertTrue(rList is ConnectorListResult.Error)
assertTrue((rList as ConnectorListResult.Error).error is ConnectorError.NetworkError)
val rConnect = repo.connect("sess-1", listOf("linear"))
assertTrue(rConnect is ConnectorConnectResult.Error)
assertTrue((rConnect as ConnectorConnectResult.Error).error is ConnectorError.NetworkError)
}
@Test
fun `default rpcRequest cancels underlying deferred on caller cancellation for listConnectors`() =
runBlocking {
mockkObject(HermesWsClient)
try {
val deferred = CompletableDeferred<Any?>()
every { HermesWsClient.request(WsMethods.CONNECTORS_LIST, any(), any()) } returns deferred
every { HermesWsClient.request(WsMethods.CONNECTORS_LIST, any()) } returns deferred
val repo = HermesConnectorRepository()
val job =
launch {
repo.listConnectors("sess-cancel-test")
}
yield()
assertFalse("Deferred must not be cancelled while waiting", deferred.isCancelled)
job.cancel()
job.join()
assertTrue("Deferred must be cancelled when caller coroutine is cancelled", deferred.isCancelled)
} finally {
unmockkObject(HermesWsClient)
}
}
@Test
fun `default rpcRequest cancels underlying deferred on caller cancellation for connect`() =
runBlocking {
mockkObject(HermesWsClient)
try {
val deferred = CompletableDeferred<Any?>()
every { HermesWsClient.request(WsMethods.CONNECTORS_CONNECT, any(), any()) } returns deferred
every { HermesWsClient.request(WsMethods.CONNECTORS_CONNECT, any()) } returns deferred
val repo = HermesConnectorRepository()
val job =
launch {
repo.connect("sess-cancel-test", listOf("linear"))
}
yield()
assertFalse("Deferred must not be cancelled while waiting", deferred.isCancelled)
job.cancel()
job.join()
assertTrue("Deferred must be cancelled when caller coroutine is cancelled", deferred.isCancelled)
} finally {
unmockkObject(HermesWsClient)
}
}
}
@@ -0,0 +1,458 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class HermesWsClientLoggingSecurityTest {
private val capturedDebugLogs = Collections.synchronizedList(mutableListOf<String>())
private val capturedInfoLogs = Collections.synchronizedList(mutableListOf<String>())
private val capturedWarnLogs = Collections.synchronizedList(mutableListOf<String>())
private val capturedErrorLogs = Collections.synchronizedList(mutableListOf<String>())
@Before
fun setUp() {
capturedDebugLogs.clear()
capturedInfoLogs.clear()
capturedWarnLogs.clear()
capturedErrorLogs.clear()
mockkStatic(Log::class)
every { Log.v(any<String>(), any<String>()) } answers {
capturedDebugLogs.add(secondArg())
0
}
every { Log.d(any<String>(), any<String>()) } answers {
capturedDebugLogs.add(secondArg())
0
}
every { Log.i(any<String>(), any<String>()) } answers {
capturedInfoLogs.add(secondArg())
0
}
every { Log.w(any<String>(), any<String>()) } answers {
capturedWarnLogs.add(secondArg())
0
}
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } answers {
capturedWarnLogs.add(secondArg())
0
}
every { Log.e(any<String>(), any<String>()) } answers {
capturedErrorLogs.add(secondArg())
0
}
every { Log.e(any<String>(), any<String>(), any<Throwable>()) } answers {
capturedErrorLogs.add(secondArg())
0
}
mockkObject(AuthManager)
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>(null)
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.wsUrl() } returns "ws://127.0.0.1:9119/"
CookieManager.setJarForTest(buildFakePersistentCookieJar())
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as AtomicBoolean).set(false)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as AtomicInteger).set(1)
HermesWsClient.rejectAllPending()
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.setAppForeground(true)
}
@After
fun tearDown() {
HermesWsClient.rejectAllPending()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
unmockkAll()
}
private fun createWsListener(generation: Int? = null): WebSocketListener {
val targetGen =
generation ?: run {
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as AtomicInteger).get()
}
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
return constructor.newInstance(targetGen) as WebSocketListener
}
private fun attachMockSocket(): WebSocket {
val socket = mockk<WebSocket>(relaxed = true)
every { socket.send(any<String>()) } returns true
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, socket)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as AtomicBoolean).set(true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
return socket
}
@Suppress("UNCHECKED_CAST")
private fun getPendingCalls(): ConcurrentHashMap<String, Any> {
val field = HermesWsClient::class.java.getDeclaredField("pendingCalls")
field.isAccessible = true
return field.get(HermesWsClient) as ConcurrentHashMap<String, Any>
}
private fun getTimeoutJob(pendingCall: Any): Job? {
val field = pendingCall.javaClass.getDeclaredField("timeoutJob")
field.isAccessible = true
return field.get(pendingCall) as? Job
}
// ── Formatter unit tests ──────────────────────────────────────────────────
@Test
fun `formatSafeOutgoingFrameLog does not leak payload parameters or urls`() {
val sensitiveUrl = "https://example.com/oauth/authorize?token=secret123"
val log =
HermesWsClient.formatSafeOutgoingFrameLog(
id = "req-42",
method = WsMethods.CONNECTORS_CONNECT,
paramsKeys = listOf("session_id", "connectors", "reconnect"),
byteLength = 256,
queued = false,
)
assertFalse("Log must not contain sensitive URL", log.contains(sensitiveUrl))
assertFalse("Log must not contain 'secret'", log.contains("secret"))
assertTrue("Log should contain id", log.contains("id=req-42"))
assertTrue("Log should contain method", log.contains("method=connectors.connect"))
assertTrue(
"Log should contain parameter keys only",
log.contains("paramsKeys=[session_id,connectors,reconnect]"),
)
assertTrue("Log should contain byte length", log.contains("bytes=256"))
}
@Test
fun `formatSafeQueuedFrameLog does not leak sensitive payload`() {
val sensitiveUrl = "https://auth.provider.com/login?key=supersecret"
val rawJson =
buildJsonObject {
put("id", "q-1")
put("method", "connectors.connect")
put(
"params",
buildJsonObject {
put("session_id", "sid-1")
put("secret_url", sensitiveUrl)
},
)
}.toString()
val log = HermesWsClient.formatSafeQueuedFrameLog(rawJson)
assertFalse("Queued frame log must not leak sensitive url", log.contains(sensitiveUrl))
assertFalse("Queued frame log must not contain 'supersecret'", log.contains("supersecret"))
assertTrue("Queued frame log should mark as queued", log.contains("→ (queued)"))
assertTrue("Queued frame log should show method", log.contains("method=connectors.connect"))
}
@Test
fun `formatSafeIncomingFrameLog does not leak connect_url or token from response`() {
val sensitiveAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?client_id=123&token=abc_secret_token"
val responseJson =
"""
{
"jsonrpc": "2.0",
"id": "req-99",
"result": {
"results": [
{
"connector": "google",
"status": "initiated",
"connect_url": "$sensitiveAuthUrl",
"instruction": "Open this link in your browser to sign in"
}
],
"summary": {"total": 1}
}
}
""".trimIndent()
val log = HermesWsClient.formatSafeIncomingFrameLog(responseJson)
assertFalse("Incoming frame log must not leak sensitive auth url", log.contains(sensitiveAuthUrl))
assertFalse("Incoming frame log must not leak secret token", log.contains("abc_secret_token"))
assertFalse("Incoming frame log must not leak instructions", log.contains("Open this link"))
assertTrue("Incoming frame log should contain id", log.contains("id=req-99"))
assertTrue("Incoming frame log should summarize result keys", log.contains("result=[results,summary]"))
}
@Test
fun `formatSafeIncomingFrameLog handles malformed or non-object json safely`() {
val log1 = HermesWsClient.formatSafeIncomingFrameLog("plain string not json")
assertTrue(log1.startsWith("← frame bytes="))
val log2 = HermesWsClient.formatSafeIncomingFrameLog("[1, 2, 3]")
assertTrue(log2.startsWith("← non-object frame bytes="))
}
// ── Real inbound WsListenerImpl.onMessage path regression tests ───────────
@Test
fun `inbound onMessage delivers result with connect_url while Log_d redacts secrets`() {
val socket = attachMockSocket()
val listener = createWsListener()
val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider"))
val pendingCalls = getPendingCalls()
val reqId = pendingCalls.keys.firstOrNull()
assertNotNull("Pending call ID must exist", reqId)
val sensitiveAuthUrl =
"https://auth.synthetic-fixture.local/oauth/authorize?code=fixture-code-99&token=fixture-secret-token-xyz"
val responseJson =
"""
{
"jsonrpc": "2.0",
"id": "$reqId",
"result": {
"status": "initiated",
"connect_url": "$sensitiveAuthUrl"
}
}
""".trimIndent()
listener.onMessage(socket, responseJson)
assertTrue("Deferred request must be completed", deferred.isCompleted)
val result = runBlocking { deferred.await() }
assertTrue("Result must be delivered as JsonObject", result is JsonObject)
val resultObj = result as JsonObject
assertEquals(
sensitiveAuthUrl,
(resultObj["connect_url"] as? JsonPrimitive)?.content,
)
val allLogs = capturedDebugLogs + capturedInfoLogs + capturedWarnLogs + capturedErrorLogs
for (log in allLogs) {
assertFalse("Captured log must not leak sensitive auth URL: $log", log.contains(sensitiveAuthUrl))
assertFalse("Captured log must not leak secret token: $log", log.contains("fixture-secret-token-xyz"))
assertFalse("Captured log must not leak fixture code: $log", log.contains("fixture-code-99"))
assertFalse(
"Captured log must not leak synthetic auth host: $log",
log.contains("https://auth.synthetic-fixture.local"),
)
}
assertTrue(
"Log.d must record safe sanitized incoming frame log with id and sanitized keys",
capturedDebugLogs.any {
it.contains("id=$reqId") && (
it.contains("result=[status,connect_url]") ||
it.contains("result=[connect_url,status]")
)
},
)
}
@Test
fun `inbound onMessage rpc error 4001 NOT_OWNER completes deferred exceptionally and preserves shared events`() {
val socket = attachMockSocket()
val listener = createWsListener()
val capturedEvents = Collections.synchronizedList(mutableListOf<WsEvent>())
val subscribedLatch = CountDownLatch(1)
val errorEventLatch = CountDownLatch(1)
var receivedErrorEvent: WsEvent.RpcError? = null
val collectJob =
CoroutineScope(Dispatchers.IO).launch {
HermesWsClient.events
.onSubscription { subscribedLatch.countDown() }
.collect {
capturedEvents.add(it)
if (it is WsEvent.RpcError) {
receivedErrorEvent = it
errorEventLatch.countDown()
}
}
}
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001"))
val pendingCalls = getPendingCalls()
val reqId = pendingCalls.keys.firstOrNull()
assertNotNull("Pending call ID must exist", reqId)
val errorJson =
"""
{
"jsonrpc": "2.0",
"id": "$reqId",
"error": {
"code": 4001,
"message": "Access denied: session ownership required",
"data": {
"reason": "NOT_OWNER",
"session_id": "sess-ownership-4001"
}
}
}
""".trimIndent()
listener.onMessage(socket, errorJson)
assertTrue("Deferred must be completed", deferred.isCompleted)
val thrown = runCatching { runBlocking { deferred.await() } }.exceptionOrNull()
assertTrue(
"Deferred must complete exceptionally with HermesRpcException",
thrown is HermesWsClient.HermesRpcException,
)
val rpcException = thrown as HermesWsClient.HermesRpcException
assertEquals(4001, rpcException.code)
assertEquals("Access denied: session ownership required", rpcException.message)
assertNotNull("Exception data must be preserved", rpcException.data)
assertTrue("Exception data must be JsonObject", rpcException.data is JsonObject)
val dataObj = rpcException.data as JsonObject
assertEquals("NOT_OWNER", (dataObj["reason"] as? JsonPrimitive)?.content)
assertEquals("sess-ownership-4001", (dataObj["session_id"] as? JsonPrimitive)?.content)
assertTrue("Shared events flow must receive RpcError event", errorEventLatch.await(3, TimeUnit.SECONDS))
collectJob.cancel()
assertNotNull("Captured RpcError event must exist", receivedErrorEvent)
assertEquals(reqId, receivedErrorEvent!!.id)
assertEquals(4001, receivedErrorEvent!!.error.code)
assertEquals("Access denied: session ownership required", receivedErrorEvent!!.error.message)
val eventDataObj = receivedErrorEvent!!.error.data as? JsonObject
assertNotNull("Event error data must be JsonObject", eventDataObj)
assertEquals("NOT_OWNER", (eventDataObj!!["reason"] as? JsonPrimitive)?.content)
}
@Test
fun `inbound onMessage malformed frame logs safely without leaking payload or secrets`() {
val socket = attachMockSocket()
val listener = createWsListener()
val subscribedLatch = CountDownLatch(1)
val unknownEventLatch = CountDownLatch(1)
var receivedUnknownEvent: WsEvent.Unknown? = null
val collectJob =
CoroutineScope(Dispatchers.IO).launch {
HermesWsClient.events
.onSubscription { subscribedLatch.countDown() }
.collect {
if (it is WsEvent.Unknown) {
receivedUnknownEvent = it
unknownEventLatch.countDown()
}
}
}
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
capturedDebugLogs.clear()
capturedErrorLogs.clear()
val sensitiveSecret = "synthetic-secret-token-malformed-frame-777"
val sensitiveUrl = "https://auth.synthetic-trap.local/oauth/secret"
val malformedPayload = """{"token": "$sensitiveSecret", "url": "$sensitiveUrl", [syntax-broken-json]}"""
listener.onMessage(socket, malformedPayload)
assertTrue("Unknown event must be emitted for malformed frame", unknownEventLatch.await(3, TimeUnit.SECONDS))
collectJob.cancel()
assertNotNull(receivedUnknownEvent)
assertEquals(malformedPayload, receivedUnknownEvent!!.raw)
val allLogs = capturedDebugLogs + capturedInfoLogs + capturedWarnLogs + capturedErrorLogs
for (log in allLogs) {
assertFalse("Logs must not contain sensitive secret token: $log", log.contains(sensitiveSecret))
assertFalse("Logs must not contain sensitive auth URL: $log", log.contains(sensitiveUrl))
}
assertTrue(
"Log.e must record parse failure without leaking secrets",
capturedErrorLogs.any { it.startsWith("Failed to parse message:") },
)
}
@Test
fun `cancelling pending deferred cleans pending requests and cancels timeout job`() {
attachMockSocket()
val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture"))
val pendingCalls = getPendingCalls()
val entry =
pendingCalls.entries.firstOrNull {
it.value.javaClass.name
.contains("PendingCall")
}
assertNotNull("Pending call must be registered in pendingCalls map", entry)
val reqId = entry!!.key
val timeoutJob = getTimeoutJob(entry.value)
assertNotNull("Timeout job must be initialized", timeoutJob)
assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive)
deferred.cancel()
assertTrue("Deferred must be cancelled", deferred.isCancelled)
assertFalse(
"Pending call must be removed from pendingCalls map on cancellation",
pendingCalls.containsKey(reqId),
)
assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled)
}
}
@@ -161,6 +161,26 @@ class WsProfileParamsTest {
assertSame(original, result)
}
@Test
fun `connectors_list is not profile scoped`() {
AuthManager.setActiveProfileId("meow")
val original = mapOf("session_id" to "sess-123")
val result = WsProfileParams.decorate(WsMethods.CONNECTORS_LIST, original)
assertSame(original, result)
}
@Test
fun `connectors_connect is not profile scoped`() {
AuthManager.setActiveProfileId("meow")
val original = mapOf("session_id" to "sess-123", "connectors" to listOf("linear"), "reconnect" to false)
val result = WsProfileParams.decorate(WsMethods.CONNECTORS_CONNECT, original)
assertSame(original, result)
}
@Test
fun `unknown method is not profile scoped`() {
AuthManager.setActiveProfileId("meow")
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,146 @@
package com.m57.hermescontrol.ui.chat
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 io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatConnectorsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockDelegate = mockk<ChatConnectorsDelegate>(relaxed = true)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
ActiveSessionHolder.clear()
HermesWsClient.setConnectionStatusForTest(ConnectionStatus.DISCONNECTED)
}
@After
fun tearDown() {
ActiveSessionHolder.clear()
HermesWsClient.setConnectionStatusForTest(ConnectionStatus.DISCONNECTED)
AuthManager.resetAuthStateForTest()
Dispatchers.resetMain()
}
@Test
fun testInitialSync_triggersOnActiveSessionChangedWithInitialSession() {
ActiveSessionHolder.set("sess-init")
ChatConnectorsViewModel(delegate = mockDelegate)
testDispatcher.scheduler.advanceUntilIdle()
verify { mockDelegate.onActiveSessionChanged("sess-init") }
}
@Test
fun testSessionChange_triggersOnActiveSessionChanged() {
ActiveSessionHolder.set("sess-1")
ChatConnectorsViewModel(delegate = mockDelegate)
testDispatcher.scheduler.advanceUntilIdle()
ActiveSessionHolder.set("sess-2")
testDispatcher.scheduler.advanceUntilIdle()
verify { mockDelegate.onActiveSessionChanged("sess-2") }
}
@Test
fun testProfileChange_triggersOnActiveSessionChanged() {
ActiveSessionHolder.set("sess-1")
ChatConnectorsViewModel(delegate = mockDelegate)
testDispatcher.scheduler.advanceUntilIdle()
AuthManager.setActiveProfileId("profile-new")
testDispatcher.scheduler.advanceUntilIdle()
verify(atLeast = 1) { mockDelegate.onActiveSessionChanged("sess-1") }
}
@Test
fun testServerChange_triggersOnActiveSessionChanged() {
ActiveSessionHolder.set("sess-1")
ChatConnectorsViewModel(delegate = mockDelegate)
testDispatcher.scheduler.advanceUntilIdle()
AuthManager.setBaseUrlForTest("http://192.168.1.199:8080")
testDispatcher.scheduler.advanceUntilIdle()
verify(atLeast = 1) { mockDelegate.onActiveSessionChanged("sess-1") }
}
@Test
fun testReconnect_triggersOnTransportReconnected() {
ChatConnectorsViewModel(delegate = mockDelegate)
testDispatcher.scheduler.advanceUntilIdle()
HermesWsClient.setConnectionStatusForTest(ConnectionStatus.CONNECTED)
testDispatcher.scheduler.advanceUntilIdle()
verify { mockDelegate.onTransportReconnected() }
}
@Test
fun testForwardsUiMethodsToDelegate() {
val uiStateFlow = MutableStateFlow(ChatConnectorsUiState(isVisible = true))
val delegate =
mockk<ChatConnectorsDelegate>(relaxed = true) {
io.mockk.every { uiState } returns uiStateFlow
io.mockk.every { takeBrowserEvent(any()) } returns
ConnectBrowserEvent(
slug = "slug",
url = "https://auth.example.com",
)
}
val viewModel = ChatConnectorsViewModel(delegate = delegate)
assertEquals(true, viewModel.uiState.value.isVisible)
viewModel.show()
verify { delegate.show() }
viewModel.hide()
verify { delegate.hide() }
viewModel.onResume()
verify { delegate.onResume() }
viewModel.onPause()
verify { delegate.onPause() }
viewModel.refresh(force = true)
verify { delegate.refresh(force = true) }
viewModel.connect("github", reconnect = true)
verify { delegate.connect("github", reconnect = true) }
val event = viewModel.takeBrowserEvent(123L)
assertEquals("slug", event?.slug)
verify { delegate.takeBrowserEvent(123L) }
viewModel.launchError("test error")
verify { delegate.launchError("test error") }
viewModel.clearError()
verify { delegate.clearError() }
}
}
@@ -0,0 +1,68 @@
package com.m57.hermescontrol.util
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectorUrlValidatorTest {
@Test
fun `valid https urls are accepted`() {
assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://auth.hermes.nousresearch.com/oauth/google"))
assertTrue(
ConnectorUrlValidator.isValidHttpsUrl("https://github.com/login/oauth/authorize?client_id=123&scope=repo"),
)
assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://linear.app/oauth/authorize?response_type=code"))
assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://slack.com/oauth/v2/authorize"))
assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:8443/auth?foo=bar#section"))
assertTrue(ConnectorUrlValidator.isValidHttpsUrl("HTTPS://EXAMPLE.COM/AUTH"))
}
@Test
fun `null, empty, and whitespace strings are rejected`() {
assertFalse(ConnectorUrlValidator.isValidHttpsUrl(null))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl(""))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl(" "))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("\t\n"))
}
@Test
fun `non-https schemes are rejected`() {
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("http://example.com/auth"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("ftp://example.com/file"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("javascript:alert(1)"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("data:text/html,<html>"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("file:///etc/passwd"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("intent://example.com"))
}
@Test
fun `urls with userinfo are rejected`() {
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://user:password@example.com/oauth"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://user@example.com/oauth"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://admin:@example.com/oauth"))
}
@Test
fun `urls with backslashes are rejected`() {
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com\\evil.com"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/path\\something"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https:\\\\example.com"))
}
@Test
fun `urls with control characters or whitespace are rejected`() {
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth\u0000"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth\r\nSet-Cookie:bad"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth test"))
}
@Test
fun `urls with invalid host or port are rejected`() {
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https:///path"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:0/auth"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:65536/auth"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:99999/auth"))
assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://invalid_host_chars!/auth"))
}
}