23 files changed
+6579
-6
No files matched your search
+413
@@ -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.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKeys
|
import androidx.security.crypto.MasterKeys
|
||||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||||
@@ -500,6 +501,11 @@ object AuthManager {
|
|||||||
_baseUrlFlow.value = ""
|
_baseUrlFlow.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun setBaseUrlForTest(baseUrl: String) {
|
||||||
|
_baseUrlFlow.value = baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
fun getToken(): String? {
|
fun getToken(): String? {
|
||||||
if (tokenInitialized) return cachedToken
|
if (tokenInitialized) return cachedToken
|
||||||
synchronized(this) {
|
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.NetworkMonitor
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -30,6 +31,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
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. */
|
/** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */
|
||||||
class HermesRpcException(
|
class HermesRpcException(
|
||||||
message: String,
|
message: String,
|
||||||
|
val code: Int = 0,
|
||||||
|
val data: JsonElement? = null,
|
||||||
) : Exception(message)
|
) : Exception(message)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -741,6 +746,15 @@ object HermesWsClient {
|
|||||||
send(method, params) { reqId ->
|
send(method, params) { reqId ->
|
||||||
pendingCalls[reqId] = PendingCall(method, deferred)
|
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).
|
// Arm the per-request timeout (fires if the server never answers).
|
||||||
pendingCalls[id]?.timeoutJob =
|
pendingCalls[id]?.timeoutJob =
|
||||||
wsScope.launch {
|
wsScope.launch {
|
||||||
@@ -760,7 +774,13 @@ object HermesWsClient {
|
|||||||
removeQueuedMessage(id)
|
removeQueuedMessage(id)
|
||||||
call.timeoutJob?.cancel()
|
call.timeoutJob?.cancel()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
call.deferred.completeExceptionally(HermesRpcException(error.message))
|
call.deferred.completeExceptionally(
|
||||||
|
HermesRpcException(
|
||||||
|
message = error.message,
|
||||||
|
code = error.code,
|
||||||
|
data = error.data,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
call.deferred.complete(result)
|
call.deferred.complete(result)
|
||||||
}
|
}
|
||||||
@@ -808,7 +828,9 @@ object HermesWsClient {
|
|||||||
params = decoratedParams.mapValues { it.value.toJsonElement() },
|
params = decoratedParams.mapValues { it.value.toJsonElement() },
|
||||||
)
|
)
|
||||||
val json = OkHttpProvider.json.encodeToString(request)
|
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
|
var reconnect = false
|
||||||
synchronized(outboundLock) {
|
synchronized(outboundLock) {
|
||||||
if (method == WsMethods.PROMPT_SUBMIT) {
|
if (method == WsMethods.PROMPT_SUBMIT) {
|
||||||
@@ -1191,7 +1213,7 @@ object HermesWsClient {
|
|||||||
markQueuedMessageSent(msg)
|
markQueuedMessageSent(msg)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg")
|
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg))
|
||||||
if (!webSocket.send(msg)) {
|
if (!webSocket.send(msg)) {
|
||||||
recoverRejectedSocket(webSocket)
|
recoverRejectedSocket(webSocket)
|
||||||
break
|
break
|
||||||
@@ -1209,7 +1231,7 @@ object HermesWsClient {
|
|||||||
text: String,
|
text: String,
|
||||||
) {
|
) {
|
||||||
if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return
|
if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
|
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text))
|
||||||
lastPongTimestamp = monotonicTimeMs()
|
lastPongTimestamp = monotonicTimeMs()
|
||||||
// Resolve any in-flight `request()` awaiting this RPC result/error
|
// Resolve any in-flight `request()` awaiting this RPC result/error
|
||||||
// (issue #526) before fanning the parsed event out to collectors.
|
// (issue #526) before fanning the parsed event out to collectors.
|
||||||
@@ -1269,7 +1291,7 @@ object HermesWsClient {
|
|||||||
parsed
|
parsed
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to parse message", e)
|
Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}")
|
||||||
WsEvent.Unknown(text)
|
WsEvent.Unknown(text)
|
||||||
}
|
}
|
||||||
when (event) {
|
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. */
|
/** Rolling tail snapshot of a subagent's live execution transcript. */
|
||||||
const val SUBAGENT_TAIL = "subagent.tail"
|
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) ─────────────────────────────
|
// ── Billing / subscription (issue #628) ─────────────────────────────
|
||||||
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
||||||
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
|
// `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
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ContextWrapper
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -44,6 +50,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -58,11 +65,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
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.ReactionHeartsOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
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.SideQuestionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
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.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||||
|
import com.m57.hermescontrol.util.ConnectorUrlValidator
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -143,10 +154,60 @@ fun ChatScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
||||||
val credentialWarning by HermesWsClient.credentialWarning.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
|
// Snapshot-backed search state — read directly so only the scopes that
|
||||||
// read its fields recompose on search changes (bar, matched bubbles).
|
// read its fields recompose on search changes (bar, matched bubbles).
|
||||||
val searchState = viewModel.searchState
|
val searchState = viewModel.searchState
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
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 listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
||||||
val scrollScope = rememberCoroutineScope()
|
val scrollScope = rememberCoroutineScope()
|
||||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||||
@@ -299,7 +360,6 @@ fun ChatScreen(
|
|||||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val context = LocalContext.current
|
|
||||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
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 =
|
val mediaLaunchers =
|
||||||
rememberChatMediaLaunchers(
|
rememberChatMediaLaunchers(
|
||||||
inputFieldValue = inputFieldValue,
|
inputFieldValue = inputFieldValue,
|
||||||
@@ -428,6 +527,39 @@ fun ChatScreen(
|
|||||||
contentDescription = stringResource(R.string.content_desc_new_chat),
|
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(
|
Column(
|
||||||
@@ -763,5 +895,14 @@ fun ChatScreen(
|
|||||||
onDismiss = { viewingImage = null },
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+752
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1198,4 +1198,36 @@
|
|||||||
<string name="group_chat_action_settings">الإعدادات</string>
|
<string name="group_chat_action_settings">الإعدادات</string>
|
||||||
<string name="group_chat_stopped_by_user">تم إيقاف النقاش</string>
|
<string name="group_chat_stopped_by_user">تم إيقاف النقاش</string>
|
||||||
<string name="group_chat_action_stop">إيقاف التوليد</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>
|
</resources>
|
||||||
@@ -888,4 +888,36 @@
|
|||||||
<string name="chat_fast_mode_label">빠름</string>
|
<string name="chat_fast_mode_label">빠름</string>
|
||||||
<string name="chat_fast_mode_desc">빠른 모드</string>
|
<string name="chat_fast_mode_desc">빠른 모드</string>
|
||||||
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</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>
|
</resources>
|
||||||
@@ -1175,4 +1175,36 @@
|
|||||||
<string name="group_chat_settings_high_limits_confirm">仍要保存</string>
|
<string name="group_chat_settings_high_limits_confirm">仍要保存</string>
|
||||||
<string name="group_chat_settings_high_limits_adjust">调整限制</string>
|
<string name="group_chat_settings_high_limits_adjust">调整限制</string>
|
||||||
<string name="group_chat_action_settings">设置</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>
|
</resources>
|
||||||
@@ -1387,4 +1387,36 @@
|
|||||||
<string name="vault_code_label">Verification Code</string>
|
<string name="vault_code_label">Verification Code</string>
|
||||||
<string name="vault_code_action">Submit</string>
|
<string name="vault_code_action">Submit</string>
|
||||||
<string name="vault_code_dismiss">Skip</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>
|
</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)
|
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
|
@Test
|
||||||
fun `unknown method is not profile scoped`() {
|
fun `unknown method is not profile scoped`() {
|
||||||
AuthManager.setActiveProfileId("meow")
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user