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