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

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

No files matched your search

@@ -0,0 +1,942 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorConnectStatus
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorItem
import com.m57.hermescontrol.data.model.ConnectorListResult
import com.m57.hermescontrol.data.model.ConnectorStatus
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.ConnectorRepository
import com.m57.hermescontrol.util.ConnectorUrlValidator
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Explicit load phases for connector list rendering.
*/
sealed interface ConnectorsLoadPhase {
data object Initial : ConnectorsLoadPhase
data object Loading : ConnectorsLoadPhase
data class Refreshing(
val previousItems: List<ConnectorUiItem>,
) : ConnectorsLoadPhase
data class Loaded(
val available: Boolean,
) : ConnectorsLoadPhase
data class Error(
val error: ConnectorError,
val canRetry: Boolean = true,
) : ConnectorsLoadPhase
data class Unavailable(
val message: String,
) : ConnectorsLoadPhase
data class UnsupportedRuntime(
val message: String,
) : ConnectorsLoadPhase
data class UnsupportedBackend(
val message: String,
) : ConnectorsLoadPhase
data class NotOwner(
val message: String,
) : ConnectorsLoadPhase
}
/**
* Mutating action phases for individual connector operations.
*/
sealed interface ConnectorActionState {
data object Idle : ConnectorActionState
data class Connecting(
val slug: String,
val isReconnect: Boolean,
) : ConnectorActionState
data class AwaitingAuthorization(
val slug: String,
) : ConnectorActionState
}
/**
* Immutable safe UI item representation. Raw connect URLs are strictly omitted.
*/
data class ConnectorUiItem(
val slug: String,
val name: String,
val isConnected: Boolean,
val isEnabled: Boolean,
val status: ConnectorStatus,
val connectionStatus: String?,
) {
companion object {
fun from(item: ConnectorItem): ConnectorUiItem =
ConnectorUiItem(
slug = item.connector,
name = item.name,
isConnected = item.connected,
isEnabled = item.enabled,
status = item.status,
connectionStatus = item.connectionStatus,
)
}
}
/**
* Transient one-shot browser launch event. Redacts URL in [toString].
*/
data class ConnectBrowserEvent(
val slug: String,
val url: String,
val eventId: Long = System.nanoTime(),
val sessionId: String? = null,
val generation: Long = 0L,
) {
override fun toString(): String =
"ConnectBrowserEvent(slug=$slug, url=[REDACTED], eventId=$eventId, sessionId=$sessionId, generation=$generation)"
}
/**
* Complete UI state exposed to the session integrations view.
*/
data class ChatConnectorsUiState(
val isVisible: Boolean = false,
val loadPhase: ConnectorsLoadPhase = ConnectorsLoadPhase.Initial,
val items: List<ConnectorUiItem> = emptyList(),
val actionState: ConnectorActionState = ConnectorActionState.Idle,
val browserLaunchEvent: ConnectBrowserEvent? = null,
val errorMessage: String? = null,
val isOldBackend: Boolean = false,
val sessionId: String? = null,
val generation: Long = 0L,
) {
val isLoading: Boolean
get() = loadPhase is ConnectorsLoadPhase.Loading || actionState is ConnectorActionState.Connecting
}
/**
* Controller / Delegate managing session Integrations (connectors) state,
* lifecycle, deduplication, and authorization flows (issue #1091).
*/
class ChatConnectorsDelegate(
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val repository: ConnectorRepository = ConnectorRepository,
private val runtimeSessionId: () -> String? = { ActiveSessionHolder.activeSessionId.value },
) {
private val _uiState = MutableStateFlow(ChatConnectorsUiState())
val uiState: StateFlow<ChatConnectorsUiState> = _uiState.asStateFlow()
private var isForeground: Boolean = false
private var sessionGeneration: Long = 0L
private var refreshRequestId: Long = 0L
private var refreshJob: Job? = null
private var connectJob: Job? = null
private var pendingRefreshAfterMutation: Boolean = false
private var pendingTrailingRefresh: Boolean = false
private var pendingBrowserEvent: ConnectBrowserEvent? = null
private var browserLaunched: Boolean = false
/**
* Show integrations panel and trigger load if in foreground.
*/
fun show() {
if (_uiState.value.isVisible) return
val sid = runtimeSessionId()
_uiState.update {
it.copy(
isVisible = true,
sessionId = sid,
generation = sessionGeneration,
)
}
if (isForeground) {
if (!sid.isNullOrBlank() && !_uiState.value.isOldBackend) {
refresh(force = false)
}
}
}
/**
* Hide integrations panel, cancelling ongoing jobs, incrementing generation,
* and clearing transient state.
*/
fun hide() {
sessionGeneration++
refreshJob?.cancel()
refreshJob = null
connectJob?.cancel()
connectJob = null
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
browserLaunched = false
_uiState.update {
it.copy(
isVisible = false,
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
generation = sessionGeneration,
)
}
}
/**
* Lifecycle resume hook. Refreshes once when returning from external browser authorization.
* Guarded to only execute on true paused -> resume transitions.
*/
fun onResume() {
val wasForeground = isForeground
isForeground = true
if (wasForeground) return
if (!_uiState.value.isVisible) return
val sid = runtimeSessionId()
val pending = pendingBrowserEvent
if (pending != null) {
pendingBrowserEvent = null
if (pending.generation == sessionGeneration &&
pending.sessionId == sid &&
_uiState.value.actionState is ConnectorActionState.AwaitingAuthorization
) {
_uiState.update { it.copy(browserLaunchEvent = pending) }
}
}
if (pendingRefreshAfterMutation || pendingTrailingRefresh) {
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
refresh(force = true)
return
}
val currentAction = _uiState.value.actionState
if (currentAction is ConnectorActionState.AwaitingAuthorization) {
if (browserLaunched) {
// Actual return from external browser authorization: refresh once to confirm authorization
browserLaunched = false
refresh(force = true)
return
}
// Minted unconsumed event waiting to be launched: do NOT prematurely refresh
return
}
// Returned from background: initial load or reload interrupted read if needed and not old backend
val currentPhase = _uiState.value.loadPhase
val needsCatalogLoad =
(
currentPhase is ConnectorsLoadPhase.Initial ||
currentPhase is ConnectorsLoadPhase.Loading ||
currentPhase is ConnectorsLoadPhase.Refreshing
) &&
refreshJob?.isActive != true &&
!_uiState.value.isOldBackend
if (needsCatalogLoad) {
refresh(force = false)
}
}
/**
* Lifecycle pause hook. Retains unconsumed browser event bound to session + generation.
*/
fun onPause() {
isForeground = false
val unconsumed = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent
if (unconsumed != null &&
unconsumed.generation == sessionGeneration &&
unconsumed.sessionId == runtimeSessionId()
) {
pendingBrowserEvent = unconsumed
}
_uiState.update { it.copy(browserLaunchEvent = null) }
}
/**
* Notify of active session change with generation guard (A -> B -> A).
*/
fun onActiveSessionChanged(newSessionId: String?) {
sessionGeneration++
val currentGen = sessionGeneration
refreshJob?.cancel()
refreshJob = null
connectJob?.cancel()
connectJob = null
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
browserLaunched = false
if (newSessionId.isNullOrBlank()) {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Initial,
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
isOldBackend = false,
sessionId = null,
generation = currentGen,
)
}
return
}
if (_uiState.value.isVisible && isForeground) {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Loading,
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
isOldBackend = false,
sessionId = newSessionId,
generation = currentGen,
)
}
executeRefresh(newSessionId, currentGen)
} else {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Initial,
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
isOldBackend = false,
sessionId = newSessionId,
generation = currentGen,
)
}
}
}
/**
* Transport reconnected notification. Refreshes if visible, foreground, and backend supports connectors.
*/
fun onTransportReconnected() {
if (_uiState.value.isVisible && isForeground && !_uiState.value.isOldBackend) {
val sessionId = runtimeSessionId()
if (!sessionId.isNullOrBlank()) {
refresh(force = true)
}
}
}
/**
* Request connector catalog and authorization status.
* Deduplicated and coalesced for both normal and forced calls, and blocked in background.
*/
fun refresh(force: Boolean = false) {
if (!_uiState.value.isVisible || !isForeground) return
if (_uiState.value.isOldBackend && !force) return
val sessionId = runtimeSessionId()
if (sessionId.isNullOrBlank()) return
if (_uiState.value.actionState is ConnectorActionState.Connecting) {
pendingRefreshAfterMutation = true
return
}
if (refreshJob?.isActive == true) {
if (force) {
pendingTrailingRefresh = true
}
return
}
val gen = sessionGeneration
val previousItems = _uiState.value.items
_uiState.update { current ->
if (previousItems.isEmpty()) {
current.copy(
loadPhase = ConnectorsLoadPhase.Loading,
sessionId = sessionId,
generation = gen,
)
} else {
current.copy(
loadPhase = ConnectorsLoadPhase.Refreshing(previousItems),
sessionId = sessionId,
generation = gen,
)
}
}
executeRefresh(sessionId, gen)
}
private fun executeRefresh(
targetSessionId: String,
generation: Long,
) {
val requestId = ++refreshRequestId
refreshJob =
scope.launch {
var currentSession = targetSessionId
try {
var result = withContext(ioDispatcher) { repository.listConnectors(currentSession) }
// Bounded NOT_OWNER reconciliation: re-read active session once (READ ONLY)
if (result is ConnectorListResult.Error && result.error is ConnectorError.NotOwner) {
val freshSession = runtimeSessionId()
if (!freshSession.isNullOrBlank() && freshSession != currentSession) {
currentSession = freshSession
result = withContext(ioDispatcher) { repository.listConnectors(currentSession) }
}
}
// Check target sid AND generation AND request token AND visibility before applying
val activeSid = runtimeSessionId()
if (requestId != refreshRequestId ||
generation != sessionGeneration ||
currentSession != activeSid ||
!_uiState.value.isVisible
) {
return@launch
}
when (result) {
is ConnectorListResult.Success -> {
if (!result.available) {
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
_uiState.update { current ->
current.copy(
loadPhase =
ConnectorsLoadPhase.Unavailable(
"Connectors are unavailable for this session.",
),
items = emptyList(),
actionState = ConnectorActionState.Idle,
browserLaunchEvent = null,
errorMessage = null,
sessionId = currentSession,
generation = generation,
)
}
return@launch
}
val uiItems = result.connectors.map { ConnectorUiItem.from(it) }
_uiState.update { current ->
val currentAction = current.actionState
val updatedAction =
if (currentAction is ConnectorActionState.AwaitingAuthorization) {
val matchingItem = uiItems.find { it.slug == currentAction.slug }
// End authorization if connector removed from catalog or now connected
if (matchingItem == null || matchingItem.isConnected) {
ConnectorActionState.Idle
} else {
currentAction
}
} else {
currentAction
}
current.copy(
loadPhase = ConnectorsLoadPhase.Loaded(result.available),
items = uiItems,
actionState = updatedAction,
isOldBackend = false,
errorMessage = null, // error clears on refresh success
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorListResult.Error -> {
when (val err = result.error) {
is ConnectorError.UnsupportedBackend -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message),
isOldBackend = true,
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorError.Unavailable -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Unavailable(err.message),
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorError.UnsupportedRuntime -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message),
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
is ConnectorError.NotOwner -> {
val notOwnerMsg =
"Session is not owned by this transport. Please reconnect the session."
_uiState.update {
it.copy(
loadPhase =
ConnectorsLoadPhase.NotOwner(
notOwnerMsg,
),
items = emptyList(),
actionState = ConnectorActionState.Idle,
sessionId = currentSession,
generation = generation,
)
}
}
else -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Error(err, canRetry = true),
sessionId = currentSession,
generation = generation,
)
}
}
}
}
}
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
_uiState.update {
it.copy(
loadPhase =
ConnectorsLoadPhase.Error(
ConnectorError.NetworkError(t.message ?: "Failed to load connectors"),
canRetry = true,
),
sessionId = currentSession,
generation = generation,
)
}
} finally {
refreshJob = null
if (pendingTrailingRefresh &&
generation == sessionGeneration &&
_uiState.value.isVisible
) {
if (isForeground) {
pendingTrailingRefresh = false
refresh(force = true)
}
} else {
pendingTrailingRefresh = false
}
}
}
}
/**
* Initiate authorization flow for connector [slug].
* Guarded by visibility, foreground state, catalog readiness, and item enabled status.
* Never replays connect mutations into a different session.
*/
fun connect(
slug: String,
reconnect: Boolean = false,
) {
if (!_uiState.value.isVisible || !isForeground) return
if (_uiState.value.isOldBackend || _uiState.value.loadPhase is ConnectorsLoadPhase.UnsupportedBackend) {
_uiState.update { it.copy(errorMessage = "Connectors are not supported by this backend.") }
return
}
val currentPhase = _uiState.value.loadPhase
if (currentPhase !is ConnectorsLoadPhase.Loaded && currentPhase !is ConnectorsLoadPhase.Refreshing) {
_uiState.update { it.copy(errorMessage = "Cannot connect: connectors catalog is not ready.") }
return
}
val targetSession = runtimeSessionId()
if (targetSession.isNullOrBlank()) {
_uiState.update { it.copy(errorMessage = "No active session.") }
return
}
if (!ConnectorRepository.isValidSlug(slug)) {
_uiState.update { it.copy(errorMessage = "Invalid connector slug.") }
return
}
val catalogItem = _uiState.value.items.firstOrNull { it.slug == slug }
if (catalogItem == null) {
_uiState.update { it.copy(errorMessage = "Connector '$slug' not found in catalog.") }
return
}
if (!catalogItem.isEnabled) {
_uiState.update { it.copy(errorMessage = "Connector '$slug' is disabled.") }
return
}
if (_uiState.value.actionState is ConnectorActionState.Connecting) {
return
}
// Cancel any prior in-flight read before starting mutation
refreshJob?.cancel()
refreshJob = null
pendingTrailingRefresh = false
val gen = sessionGeneration
_uiState.update {
it.copy(
actionState = ConnectorActionState.Connecting(slug, reconnect),
errorMessage = null,
sessionId = targetSession,
generation = gen,
)
}
connectJob =
scope.launch {
var needsRefresh = false
try {
val result =
withContext(ioDispatcher) {
repository.connect(targetSession, listOf(slug), reconnect)
}
// Verify generation, session equality, and visibility before applying
val activeSid = runtimeSessionId()
if (gen != sessionGeneration ||
targetSession != activeSid ||
!_uiState.value.isVisible
) {
return@launch
}
when (result) {
is ConnectorConnectResult.Success -> {
// Require exact slug match; never fallback to unrelated connector
val item = result.results.firstOrNull { it.connector == slug }
if (item == null) {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Connector '$slug' not found in response.",
)
}
return@launch
}
when (item.status) {
ConnectorConnectStatus.ACTIVE -> {
_uiState.update { it.copy(actionState = ConnectorActionState.Idle) }
needsRefresh = true
}
ConnectorConnectStatus.INITIATED -> {
val safeUrl = item.safeConnectUrl
if (safeUrl != null && ConnectorUrlValidator.isValidHttpsUrl(safeUrl)) {
val event =
ConnectBrowserEvent(
slug = slug,
url = safeUrl,
sessionId = targetSession,
generation = gen,
)
browserLaunched = false
if (isForeground) {
_uiState.update {
it.copy(
actionState = ConnectorActionState.AwaitingAuthorization(slug),
browserLaunchEvent = event,
)
}
} else {
// In background: hold transiently for foreground resume
pendingBrowserEvent = event
_uiState.update {
it.copy(
actionState = ConnectorActionState.AwaitingAuthorization(slug),
browserLaunchEvent = null,
)
}
}
} else {
// Unsafe or absent URL is a retryable generic failure
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Invalid or unsafe authorization URL received.",
)
}
}
}
ConnectorConnectStatus.FAILED -> {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Connector authorization failed. Please try again.",
)
}
}
else -> {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = "Connector operation returned unexpected status.",
)
}
}
}
}
is ConnectorConnectResult.Error -> {
when (val err = result.error) {
is ConnectorError.UnsupportedBackend -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message),
actionState = ConnectorActionState.Idle,
isOldBackend = true,
items = emptyList(),
errorMessage = err.message,
)
}
}
is ConnectorError.Unavailable -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.Unavailable(err.message),
actionState = ConnectorActionState.Idle,
items = emptyList(),
errorMessage = err.message,
)
}
}
is ConnectorError.UnsupportedRuntime -> {
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message),
actionState = ConnectorActionState.Idle,
items = emptyList(),
errorMessage = err.message,
)
}
}
is ConnectorError.NotOwner -> {
val msg = "Session is not owned by this transport. Please reconnect the session."
_uiState.update {
it.copy(
loadPhase = ConnectorsLoadPhase.NotOwner(msg),
actionState = ConnectorActionState.Idle,
errorMessage = msg,
)
}
}
else -> {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = err.message,
)
}
}
}
}
}
} catch (e: CancellationException) {
_uiState.update {
if (it.actionState is ConnectorActionState.Connecting) {
it.copy(actionState = ConnectorActionState.Idle)
} else {
it
}
}
throw e
} catch (t: Throwable) {
_uiState.update {
it.copy(
actionState = ConnectorActionState.Idle,
errorMessage = t.message ?: "Connector connection failed.",
)
}
} finally {
connectJob = null
_uiState.update {
if (it.actionState is ConnectorActionState.Connecting) {
it.copy(actionState = ConnectorActionState.Idle)
} else {
it
}
}
val shouldRefresh = needsRefresh || pendingRefreshAfterMutation
if (shouldRefresh &&
gen == sessionGeneration &&
_uiState.value.isVisible
) {
if (isForeground) {
pendingRefreshAfterMutation = false
refresh(force = true)
} else {
pendingRefreshAfterMutation = true
}
} else if (gen != sessionGeneration || !_uiState.value.isVisible) {
pendingRefreshAfterMutation = false
}
}
}
}
/**
* Consume transient browser launch event (legacy no-arg API).
*/
fun consumeBrowserEvent() {
val currentSid = runtimeSessionId()
val event = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent
if (event != null &&
isForeground &&
_uiState.value.isVisible &&
event.generation == sessionGeneration &&
event.sessionId == currentSid
) {
browserLaunched = true
}
pendingBrowserEvent = null
_uiState.update { it.copy(browserLaunchEvent = null) }
}
/**
* Consume transient browser launch event validating identity, session, generation, and foreground state.
*/
fun consumeBrowserEvent(eventId: Long): ConnectBrowserEvent? {
var consumed: ConnectBrowserEvent? = null
val currentSid = runtimeSessionId()
val pending = pendingBrowserEvent
if (pending != null && pending.eventId == eventId) {
if (isForeground &&
_uiState.value.isVisible &&
pending.generation == sessionGeneration &&
pending.sessionId == currentSid
) {
consumed = pending
pendingBrowserEvent = null
} else {
pendingBrowserEvent = null
}
}
_uiState.update { current ->
val event = current.browserLaunchEvent
if (event != null && event.eventId == eventId) {
if (isForeground &&
current.isVisible &&
event.generation == sessionGeneration &&
event.sessionId == currentSid
) {
consumed = event
current.copy(browserLaunchEvent = null)
} else {
// Invalid context: clear stale event and reject handoff
current.copy(browserLaunchEvent = null)
}
} else {
current
}
}
if (consumed != null) {
browserLaunched = true
}
return consumed
}
/**
* Safely take transient browser launch event if valid.
*/
fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? {
val targetId =
eventId
?: _uiState.value.browserLaunchEvent?.eventId
?: pendingBrowserEvent?.eventId
?: return null
return consumeBrowserEvent(targetId)
}
/**
* Report an error launching the external browser intent.
*/
fun launchError(message: String) {
pendingBrowserEvent = null
browserLaunched = false
_uiState.update {
it.copy(
browserLaunchEvent = null,
actionState = ConnectorActionState.Idle,
errorMessage = message,
)
}
}
fun onLaunchError(message: String) = launchError(message)
/**
* Clear active error message.
*/
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
/**
* Teardown ongoing coroutines and invalidate state generation.
*/
fun destroy() {
sessionGeneration++
refreshJob?.cancel()
refreshJob = null
connectJob?.cancel()
connectJob = null
pendingRefreshAfterMutation = false
pendingTrailingRefresh = false
pendingBrowserEvent = null
browserLaunched = false
}
}
@@ -0,0 +1,84 @@
package com.m57.hermescontrol.ui.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
/**
* Scoped ViewModel managing session connectors / integrations lifecycle (issue #1091).
*
* Hosts [ChatConnectorsDelegate] across configuration changes, wires [ActiveSessionHolder]
* and server/profile context transitions to invalidate context on changes, and forwards
* lifecycle and browser launch events safely.
*/
class ChatConnectorsViewModel(
delegate: ChatConnectorsDelegate? = null,
) : ViewModel() {
private val delegateInstance: ChatConnectorsDelegate =
delegate ?: ChatConnectorsDelegate(
scope = viewModelScope,
runtimeSessionId = { ActiveSessionHolder.activeSessionId.value },
)
val uiState: StateFlow<ChatConnectorsUiState> = delegateInstance.uiState
init {
var lastContext: Triple<String?, String?, String?>? = null
viewModelScope.launch {
combine(
ActiveSessionHolder.activeSessionId,
AuthManager.activeProfileId,
AuthManager.baseUrlFlow,
) { sessionId, profileId, baseUrl ->
Triple(sessionId, profileId, baseUrl)
}.collect { current ->
if (lastContext == null || current != lastContext) {
lastContext = current
delegateInstance.onActiveSessionChanged(current.first)
}
}
}
viewModelScope.launch {
HermesWsClient.connectionStatus
.filter { it == ConnectionStatus.CONNECTED }
.collect {
delegateInstance.onTransportReconnected()
}
}
}
fun show() = delegateInstance.show()
fun hide() = delegateInstance.hide()
fun onResume() = delegateInstance.onResume()
fun onPause() = delegateInstance.onPause()
fun refresh(force: Boolean = false) = delegateInstance.refresh(force)
fun connect(
slug: String,
reconnect: Boolean = false,
) = delegateInstance.connect(slug, reconnect)
fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? = delegateInstance.takeBrowserEvent(eventId)
fun launchError(message: String) = delegateInstance.launchError(message)
fun clearError() = delegateInstance.clearError()
override fun onCleared() {
super.onCleared()
delegateInstance.destroy()
}
}
@@ -1,6 +1,8 @@
package com.m57.hermescontrol.ui.chat
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -30,9 +32,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -44,6 +50,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -58,11 +65,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
@@ -90,6 +99,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
@@ -104,6 +114,7 @@ import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.util.ConnectorUrlValidator
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -143,10 +154,60 @@ fun ChatScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
var browserAuthDeparted by rememberSaveable { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> {
if (browserAuthInFlight) {
browserAuthDeparted = true
}
connectorsViewModel.onPause()
}
Lifecycle.Event.ON_RESUME -> {
connectorsViewModel.onResume()
if (browserAuthInFlight && browserAuthDeparted) {
browserAuthInFlight = false
browserAuthDeparted = false
ExternalActivityLifecycleGuard.externalActivityReturned()
}
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
val activity =
generateSequence(context) { (it as? ContextWrapper)?.baseContext }
.filterIsInstance<Activity>()
.firstOrNull()
val isChangingConfigs = activity?.isChangingConfigurations == true
if (!isChangingConfigs) {
if (browserAuthInFlight && browserAuthDeparted) {
browserAuthInFlight = false
browserAuthDeparted = false
ExternalActivityLifecycleGuard.externalActivityReturned()
}
connectorsViewModel.onPause()
connectorsViewModel.hide()
}
}
}
val browserEvent = connectorsState.browserLaunchEvent
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
val scrollScope = rememberCoroutineScope()
val scrollController = rememberChatScrollController(listState, scrollScope)
@@ -299,7 +360,6 @@ fun ChatScreen(
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
val context = LocalContext.current
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
@@ -310,6 +370,45 @@ fun ChatScreen(
)
}
val invalidResponseError = stringResource(R.string.session_integrations_err_invalid_response)
val browserLaunchError = stringResource(R.string.session_integrations_err_browser_launch)
LaunchedEffect(browserEvent) {
if (browserEvent != null) {
val eventId = browserEvent.eventId
val taken = connectorsViewModel.takeBrowserEvent(eventId)
if (taken != null) {
if (!ConnectorUrlValidator.isValidHttpsUrl(taken.url)) {
connectorsViewModel.launchError(invalidResponseError)
return@LaunchedEffect
}
try {
val intent =
Intent(Intent.ACTION_VIEW, Uri.parse(taken.url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
launchExternalActivity {
browserAuthDeparted = false
browserAuthInFlight = true
context.startActivity(intent)
}
} catch (_: ActivityNotFoundException) {
browserAuthInFlight = false
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError)
} catch (_: SecurityException) {
browserAuthInFlight = false
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError)
} catch (_: Exception) {
browserAuthInFlight = false
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError)
}
}
}
}
val mediaLaunchers =
rememberChatMediaLaunchers(
inputFieldValue = inputFieldValue,
@@ -428,6 +527,39 @@ fun ChatScreen(
contentDescription = stringResource(R.string.content_desc_new_chat),
)
}
// Session actions overflow menu (issue #1091)
var showSessionMenu by remember { mutableStateOf(false) }
Box {
IconButton(
onClick = { showSessionMenu = true },
modifier = Modifier.testTag("chat_session_menu_button"),
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.session_integrations_menu_item),
)
}
DropdownMenu(
expanded = showSessionMenu,
onDismissRequest = { showSessionMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
)
},
onClick = {
showSessionMenu = false
connectorsViewModel.show()
},
modifier = Modifier.testTag("chat_menu_session_integrations"),
)
}
}
},
) { _ ->
Column(
@@ -763,5 +895,14 @@ fun ChatScreen(
onDismiss = { viewingImage = null },
)
}
SessionIntegrationsSheet(
uiState = connectorsState,
sessionId = connectorsState.sessionId,
onDismiss = connectorsViewModel::hide,
onRefresh = { connectorsViewModel.refresh(force = true) },
onConnect = { slug, reconnect -> connectorsViewModel.connect(slug, reconnect) },
onClearError = connectorsViewModel::clearError,
)
}
}
@@ -0,0 +1,752 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorStatus
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState
import com.m57.hermescontrol.ui.chat.ConnectorActionState
import com.m57.hermescontrol.ui.chat.ConnectorUiItem
import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase
/**
* Visual elements for a connector status badge.
*/
data class ConnectorStatusVisuals(
val icon: ImageVector,
val color: Color,
val label: String,
)
@Composable
fun resolveConnectorStatusVisuals(
status: ConnectorStatus,
isConnected: Boolean,
): ConnectorStatusVisuals {
val statusColors = LocalHermesStatusColors.current
return when {
isConnected || status == ConnectorStatus.CONNECTED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.CheckCircle,
color = statusColors.success,
label = stringResource(R.string.session_integrations_status_connected),
)
}
status == ConnectorStatus.INITIATED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.HourglassTop,
color = statusColors.info,
label = stringResource(R.string.session_integrations_awaiting),
)
}
status == ConnectorStatus.EXPIRED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.Warning,
color = statusColors.warning,
label = stringResource(R.string.session_integrations_status_expired),
)
}
status == ConnectorStatus.REVOKED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.Warning,
color = statusColors.warning,
label = stringResource(R.string.session_integrations_status_revoked),
)
}
status == ConnectorStatus.FAILED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.ErrorOutline,
color = statusColors.error,
label = stringResource(R.string.session_integrations_status_failed),
)
}
status == ConnectorStatus.DISCONNECTED -> {
ConnectorStatusVisuals(
icon = Icons.Filled.LinkOff,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
label = stringResource(R.string.session_integrations_status_disconnected),
)
}
else -> {
ConnectorStatusVisuals(
icon = Icons.AutoMirrored.Filled.HelpOutline,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
label = stringResource(R.string.session_integrations_status_unknown),
)
}
}
}
@Composable
fun mapLoadPhaseErrorMessage(loadPhase: ConnectorsLoadPhase): String? =
when (loadPhase) {
is ConnectorsLoadPhase.Error -> {
mapConnectorErrorMessage(loadPhase.error)
}
is ConnectorsLoadPhase.Unavailable -> {
stringResource(R.string.session_integrations_err_unavailable)
}
is ConnectorsLoadPhase.UnsupportedRuntime -> {
stringResource(
R.string.session_integrations_err_unsupported_runtime,
)
}
is ConnectorsLoadPhase.UnsupportedBackend -> {
stringResource(
R.string.session_integrations_err_unsupported_backend,
)
}
is ConnectorsLoadPhase.NotOwner -> {
stringResource(R.string.session_integrations_err_not_owner)
}
else -> {
null
}
}
@Composable
fun mapConnectorErrorMessage(error: ConnectorError): String =
when (error) {
is ConnectorError.InvalidParams -> {
stringResource(R.string.session_integrations_err_invalid_params)
}
is ConnectorError.NotOwner -> {
stringResource(R.string.session_integrations_err_not_owner)
}
is ConnectorError.Unavailable -> {
stringResource(R.string.session_integrations_err_unavailable)
}
is ConnectorError.UnsupportedRuntime -> {
stringResource(R.string.session_integrations_err_unsupported_runtime)
}
is ConnectorError.UnsupportedBackend -> {
stringResource(R.string.session_integrations_err_unsupported_backend)
}
is ConnectorError.RequestFailed -> {
stringResource(R.string.session_integrations_err_request_failed)
}
is ConnectorError.InvalidResponse, is ConnectorError.MalformedEnvelope -> {
stringResource(R.string.session_integrations_err_invalid_response)
}
is ConnectorError.NetworkError -> {
stringResource(R.string.session_integrations_err_network)
}
is ConnectorError.Other -> {
stringResource(R.string.session_integrations_err_generic)
}
}
@Composable
fun mapActionErrorMessage(rawError: String?): String =
when {
rawError.isNullOrBlank() -> {
stringResource(R.string.session_integrations_err_generic)
}
rawError.contains("not owned", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_not_owner)
}
rawError.contains(
"No active session",
ignoreCase = true,
) -> {
stringResource(R.string.session_integrations_no_session)
}
rawError.contains("Invalid connector slug", ignoreCase = true) ||
rawError.contains("Invalid params", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_invalid_params)
}
rawError.contains("authorization URL", ignoreCase = true) ||
rawError.contains("unexpected status", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_invalid_response)
}
rawError.contains("authorization failed", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_request_failed)
}
rawError.contains("browser", ignoreCase = true) -> {
stringResource(R.string.session_integrations_err_browser_launch)
}
else -> {
stringResource(R.string.session_integrations_err_generic)
}
}
/**
* Native bottom sheet for session integrations / connectors (issue #1091).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionIntegrationsSheet(
uiState: ChatConnectorsUiState,
sessionId: String? = uiState.sessionId,
onDismiss: () -> Unit,
onRefresh: () -> Unit,
onConnect: (slug: String, reconnect: Boolean) -> Unit,
onClearError: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
if (!uiState.isVisible) return
val effectiveSessionId = sessionId ?: uiState.sessionId
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier.testTag("session_integrations_sheet"),
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
SessionIntegrationsContent(
uiState = uiState,
sessionId = effectiveSessionId,
onDismiss = onDismiss,
onRefresh = onRefresh,
onConnect = onConnect,
onClearError = onClearError,
)
}
}
/**
* Stateless content composable for testing and decoupled layout.
*/
@Composable
fun SessionIntegrationsContent(
uiState: ChatConnectorsUiState,
sessionId: String? = uiState.sessionId,
onDismiss: () -> Unit,
onRefresh: () -> Unit,
onConnect: (slug: String, reconnect: Boolean) -> Unit,
onClearError: () -> Unit,
modifier: Modifier = Modifier,
) {
val statusColors = LocalHermesStatusColors.current
val effectiveSessionId = sessionId ?: uiState.sessionId
val isSessionBlank = effectiveSessionId.isNullOrBlank()
Column(
modifier =
modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
) {
// Top Header
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.session_integrations_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag("session_integrations_title"),
)
Spacer(modifier = Modifier.height(2.dp))
val subtitleText =
if (!isSessionBlank) {
val displayId =
if (effectiveSessionId.length >
12
) {
"${effectiveSessionId.take(12)}…"
} else {
effectiveSessionId
}
stringResource(R.string.session_integrations_subtitle, displayId)
} else {
stringResource(R.string.session_integrations_no_session)
}
Text(
text = subtitleText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("session_integrations_subtitle"),
)
}
IconButton(
onClick = onRefresh,
enabled = !uiState.isLoading && !isSessionBlank,
modifier = Modifier.testTag("session_integrations_refresh_button"),
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.session_integrations_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.testTag("session_integrations_close_button"),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.session_integrations_close),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Action Error Banner
if (uiState.errorMessage != null) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("session_integrations_action_error_banner"),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.ErrorOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = mapActionErrorMessage(uiState.errorMessage),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onClearError,
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.session_integrations_close),
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
}
// Awaiting Authorization Banner (manual Refresh status affordance)
val awaitingSlug =
(uiState.actionState as? ConnectorActionState.AwaitingAuthorization)?.slug
if (awaitingSlug != null) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("session_integrations_awaiting_banner"),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.OpenInBrowser,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.session_integrations_awaiting),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.session_integrations_awaiting_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.height(8.dp))
FilledTonalButton(
onClick = onRefresh,
modifier =
Modifier
.align(Alignment.End)
.testTag("session_integrations_refresh_status_button"),
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.session_integrations_refresh_status),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
// Content States
when {
isSessionBlank || (uiState.loadPhase is ConnectorsLoadPhase.Initial && uiState.items.isEmpty()) -> {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_no_session_state"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
Icon(
imageVector = Icons.Filled.LinkOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.session_integrations_no_session),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
uiState.loadPhase is ConnectorsLoadPhase.Loading -> {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_loading"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.session_integrations_connecting),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
uiState.loadPhase !is ConnectorsLoadPhase.Loading &&
uiState.loadPhase !is ConnectorsLoadPhase.Refreshing &&
uiState.loadPhase !is ConnectorsLoadPhase.Loaded &&
uiState.items.isEmpty() -> {
// Non-loaded error / unavailable / unsupported / not owner terminal states
val errorMessage = mapLoadPhaseErrorMessage(uiState.loadPhase) ?: ""
val canRetry = (uiState.loadPhase as? ConnectorsLoadPhase.Error)?.canRetry ?: false
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_error"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = statusColors.warning,
modifier = Modifier.size(36.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag("session_integrations_error_text"),
)
if (canRetry) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onRefresh,
modifier = Modifier.testTag("session_integrations_retry_button"),
) {
Text(stringResource(R.string.session_integrations_retry))
}
}
}
}
}
uiState.items.isEmpty() -> {
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.testTag("session_integrations_empty"),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp),
) {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.session_integrations_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
else -> {
// List of items
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_integrations_list"),
) {
if (uiState.loadPhase is ConnectorsLoadPhase.Refreshing) {
item {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_integrations_refreshing_indicator"),
)
}
}
items(
items = uiState.items,
key = { it.slug },
) { item ->
ConnectorRow(
item = item,
actionState = uiState.actionState,
isLoading = uiState.isLoading,
onConnect = onConnect,
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
}
}
}
}
}
@Composable
fun ConnectorRow(
item: ConnectorUiItem,
actionState: ConnectorActionState,
isLoading: Boolean,
onConnect: (slug: String, reconnect: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val visuals = resolveConnectorStatusVisuals(item.status, item.isConnected)
val isCurrentConnecting =
actionState is ConnectorActionState.Connecting && actionState.slug == item.slug
val isAnyActionPending =
actionState is ConnectorActionState.Connecting || actionState is ConnectorActionState.AwaitingAuthorization
val needsReconnect =
item.isConnected ||
item.status == ConnectorStatus.EXPIRED ||
item.status == ConnectorStatus.REVOKED
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp)
.testTag("session_integrations_item_${item.slug}"),
verticalAlignment = Alignment.CenterVertically,
) {
// Accessible Status Indicator Icon
Box(
modifier =
Modifier
.size(28.dp)
.clip(CircleShape)
.background(visuals.color.copy(alpha = 0.15f))
.testTag("session_integrations_status_${item.slug}"),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = visuals.icon,
contentDescription = visuals.label,
tint = visuals.color,
modifier = Modifier.size(16.dp),
)
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = visuals.label,
style = MaterialTheme.typography.bodySmall,
color = visuals.color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(12.dp))
if (isCurrentConnecting) {
CircularProgressIndicator(
modifier =
Modifier
.size(20.dp)
.testTag("session_integrations_spinner_${item.slug}"),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
} else {
val buttonText =
if (needsReconnect) {
stringResource(R.string.session_integrations_reconnect)
} else {
stringResource(R.string.session_integrations_connect)
}
OutlinedButton(
onClick = { onConnect(item.slug, needsReconnect) },
enabled = item.isEnabled && !isAnyActionPending && !isLoading,
modifier = Modifier.testTag("session_integrations_action_${item.slug}"),
) {
Text(
text = buttonText,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}