diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt index 35d7cadc..9e9ff63a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/CookieManager.kt @@ -45,7 +45,7 @@ object CookieManager { synchronized(this) { if (jar != null) return val store = EncryptedCookieStore(context.applicationContext, legacyPrefsDeferred) - jar = PersistentCookieJar(store, scope) + jar = PersistentCookieJar(store, scope, initialServerId) // Eagerly load the initial scope off the caller thread so the // first REST call is instant without blocking app startup. scope.launch { jar!!.useStore(initialServerId) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/PersistentCookieJar.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/PersistentCookieJar.kt index 2423fbf2..be18059e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/PersistentCookieJar.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/PersistentCookieJar.kt @@ -33,12 +33,13 @@ import java.util.concurrent.atomic.AtomicReference class PersistentCookieJar( private val store: CookieStore, private val storeScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + initialServerId: String = DEFAULT_SERVER_ID, ) : CookieJar { // serverId -> (host -> cookies) private val cache = ConcurrentHashMap>>() private val loadedScopes = ConcurrentHashMap.newKeySet() - @Volatile private var currentServerId = AtomicReference(DEFAULT_SERVER_ID) + @Volatile private var currentServerId = AtomicReference(initialServerId) private val scopeMutex = Mutex() @@ -60,6 +61,9 @@ class PersistentCookieJar( */ fun getCookie(name: String): Cookie? { val serverId = currentServerId.get() + if (!loadedScopes.contains(serverId)) { + kotlinx.coroutines.runBlocking { ensureLoaded(serverId) } + } val hosts = cache[serverId] ?: return null for (bucket in hosts.values) { synchronized(bucket) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 46bfa01a..8c659d3f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -189,7 +189,6 @@ object HermesWsClient { intentionalClose.set(false) currentBackoff = INITIAL_BACKOFF_MS _connectionStatus.value = ConnectionStatus.CONNECTING - refreshWsTicketIfNeeded() openSocket() } @@ -200,12 +199,24 @@ object HermesWsClient { * * The session cookie is attached automatically by the shared CookieJar on * OkHttpProvider.probe (issue #470), so we no longer inject it manually. + * + * Returns true if the token is ready (either because we are not in gated mode, + * or because ticket refresh succeeded). Returns false if we are in gated mode + * and ticket refresh failed or cannot be performed. */ - private fun refreshWsTicketIfNeeded() { + private fun refreshWsTicketIfNeeded(): Boolean { + val isGated = AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" + if (!isGated) { + return true + } + val sessionCookie = AuthManager.getSessionCookie() if (sessionCookie.isNullOrBlank()) { - return + Log.w(TAG, "WS ticket refresh aborted: no session cookie found in gated mode") + _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED + return false } + try { val client = OkHttpProvider.probe val request = @@ -214,20 +225,35 @@ object HermesWsClient { .url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket") .post("{}".toRequestBody()) .build() - val response = client.newCall(request).execute() + + // Run network call on Dispatchers.IO to avoid NetworkOnMainThreadException + val response = + kotlinx.coroutines.runBlocking(Dispatchers.IO) { + client.newCall(request).execute() + } + if (response.isSuccessful) { val body = response.body?.string() ?: "" - val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\""").find(body) + val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body) val ticket = ticketMatch?.groupValues?.getOrNull(1) if (!ticket.isNullOrBlank()) { AuthManager.setToken(ticket) if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed") + return true + } else { + Log.w(TAG, "WS ticket refresh failed: response body did not contain ticket") + _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED + return false } } else { Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}") + _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED + return false } } catch (e: Exception) { Log.w(TAG, "WS ticket refresh failed: ${e.message}") + _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED + return false } } @@ -375,7 +401,10 @@ object HermesWsClient { // ── Internal ───────────────────────────────────────────────────────── private fun openSocket() { - refreshWsTicketIfNeeded() + if (!refreshWsTicketIfNeeded()) { + Log.w(TAG, "Aborting openSocket: WS ticket refresh failed") + return + } val url = AuthManager.wsUrl() val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED") if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") @@ -479,7 +508,10 @@ object HermesWsClient { Log.i(TAG, "WebSocket closed: $code $reason") connected.set(false) stopHealthTracking() - if (code == 4001 || reason.contains("unauthorized", ignoreCase = true)) { + if (code == 4001 || code == 4401 || + reason.contains("unauthorized", ignoreCase = true) || + reason.startsWith("auth:", ignoreCase = true) + ) { _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED } else { _connectionStatus.value = ConnectionStatus.RECONNECTING diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt index 98e85943..11a1ba12 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt @@ -301,6 +301,7 @@ class ChannelsViewModel : onboardingExpiresIn = formatExpiry(setup?.expiresAt), ) } + is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> { it.copy( onboardingPhase = OnboardingPhase.IDLE, @@ -350,6 +351,7 @@ class ChannelsViewModel : } _uiState.update { it.copy(onboardingError = null) } } + is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> { val expired = isOnboardingExpired(_uiState.value.onboardingSetup) if (expired) { @@ -470,6 +472,7 @@ class ChannelsViewModel : loadPlatforms() } } + is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> { _uiState.update { it.copy( @@ -520,7 +523,9 @@ private fun formatExpiry(expiresAt: String?): String { val ms = try { isoTimestampParser - .parse(expiresAt)?.time?.minus(System.currentTimeMillis()) + .parse(expiresAt) + ?.time + ?.minus(System.currentTimeMillis()) } catch (_: Exception) { null } @@ -537,7 +542,8 @@ private fun isOnboardingExpired(setup: com.m57.hermescontrol.data.model.Telegram val ms = try { isoTimestampParser - .parse(expiresAt)?.time + .parse(expiresAt) + ?.time } catch (_: Exception) { null } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt index ab69e295..5d21bbed 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt @@ -186,6 +186,7 @@ internal fun TelegramOnboardingDialog( Text("Save and restart") } } + else -> {} } }, @@ -194,9 +195,11 @@ internal fun TelegramOnboardingDialog( OnboardingPhase.READY -> { TextButton(onClick = onDismiss) { Text("Cancel") } } + OnboardingPhase.WAITING -> { TextButton(onClick = onDismiss) { Text("Cancel") } } + OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {} } }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 7586f7bd..6c828d43 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -54,7 +54,9 @@ object ChatWsEventReducer { is WsEvent.MessageToken -> onMessageToken(state, streamingState, event) is WsEvent.ThinkingDelta -> onThinkingDelta(state, streamingState, event) + is WsEvent.ReasoningDelta -> onReasoningDelta(state, streamingState, event) + is WsEvent.ReasoningAvailable -> onReasoningAvailable(state, streamingState, event) is WsEvent.MessageComplete -> onMessageComplete(state, streamingState, event) @@ -86,6 +88,7 @@ object ChatWsEventReducer { // SudoRequest / SecretRequest are handled by the ViewModel (issue #524) is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt index 9cb08201..eb19a480 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysViewModel.kt @@ -55,7 +55,10 @@ class KeysViewModel : private fun buildCategoryList( envVars: Map, - expandedCategories: Set = _uiState.value.categories.map { it.name }.toSet(), + expandedCategories: Set = + _uiState.value.categories + .map { it.name } + .toSet(), ): List { // Group by category, uncategorized go to "Other" val grouped = mutableMapOf>() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index 172571ff..93c00114 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -119,6 +119,7 @@ fun McpServersScreen( state.isLoading && state.servers.isEmpty() -> { LoadingState(modifier = Modifier.padding(paddingValues)) } + state.errorMessage != null -> { ErrorState( message = state.errorMessage ?: "", @@ -126,6 +127,7 @@ fun McpServersScreen( modifier = Modifier.padding(paddingValues), ) } + else -> { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -557,12 +559,14 @@ private fun CatalogSection( } Spacer(modifier = Modifier.height(spacing.sm)) } + state.catalogError != null -> { ErrorState( message = state.catalogError ?: "", onRetry = { viewModel.loadCatalog() }, ) } + filteredCatalog.isEmpty() -> { Text( text = stringResource(R.string.mcp_servers_catalog_empty), @@ -571,6 +575,7 @@ private fun CatalogSection( modifier = Modifier.padding(vertical = spacing.md), ) } + else -> { filteredCatalog.forEach { entry -> CatalogEntryCard( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index db9391ad..888cb0b3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -112,6 +112,7 @@ class McpServersViewModel : ) } } + is NetworkResult.Failure -> { revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}") } @@ -130,6 +131,7 @@ class McpServersViewModel : is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") } } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Server '$name' test failed: ${result.error.message}") } } @@ -148,6 +150,7 @@ class McpServersViewModel : _uiState.update { it.copy(toastMessage = "Server '$name' deleted") } loadServers() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to delete server: ${result.error.message}") } } @@ -167,6 +170,7 @@ class McpServersViewModel : _uiState.update { it.copy(toastMessage = "Server '$name' restarted") } loadServers() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to restart server: ${result.error.message}") } } @@ -220,7 +224,10 @@ class McpServersViewModel : }, args = if (state.addMode == AddServerMode.Stdio && state.addServerArgs.isNotBlank()) { - state.addServerArgs.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } + state.addServerArgs + .trim() + .split("\\s+".toRegex()) + .filter { it.isNotEmpty() } } else { null }, @@ -244,6 +251,7 @@ class McpServersViewModel : } loadServers() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -300,6 +308,7 @@ class McpServersViewModel : } loadServers() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to add env var: ${result.error.message}") } } @@ -324,6 +333,7 @@ class McpServersViewModel : _uiState.update { it.copy(toastMessage = "Env var '$key' removed") } loadServers() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to remove env var: ${result.error.message}") } } @@ -349,6 +359,7 @@ class McpServersViewModel : ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -389,6 +400,7 @@ class McpServersViewModel : } loadServers() } + is NetworkResult.Failure -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt index 07e3c767..ed295633 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt @@ -29,7 +29,9 @@ enum class SkillsViewMode { HUB, } -enum class SkillFilter(val labelRes: Int) { +enum class SkillFilter( + val labelRes: Int, +) { ALL_STATUSES(R.string.skills_status_all), ENABLED(R.string.skills_status_enabled), DISABLED(R.string.skills_status_disabled), @@ -74,8 +76,9 @@ data class SkillsUiState( val sourceFilter: String? = null, ) -class SkillsViewModel(application: Application) : - AndroidViewModel(application), +class SkillsViewModel( + application: Application, +) : AndroidViewModel(application), ToastHost { private val _uiState = MutableStateFlow(SkillsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -189,6 +192,7 @@ class SkillsViewModel(application: Application) : ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -238,6 +242,7 @@ class SkillsViewModel(application: Application) : // Refresh installed skills loadSkills() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -274,6 +279,7 @@ class SkillsViewModel(application: Application) : } loadSkills() } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -311,6 +317,7 @@ class SkillsViewModel(application: Application) : ) } } + is NetworkResult.Failure -> { _uiState.update { it.copy( @@ -469,6 +476,7 @@ class SkillsViewModel(application: Application) : _uiState.update { it.copy(toastMessage = "Skills updated") } loadSkills() } + is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Update failed: ${result.error.message}") } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 1b5a8627..8139460e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -509,12 +509,14 @@ class ChatViewModelTest { assertEquals(2, viewModel.uiState.value.messages.size) assertEquals( "The answer is 42", - viewModel.uiState.value.messages[1].content, + viewModel.uiState.value.messages[1] + .content, ) // reasoning carried onto the finalized UI message assertEquals( "Let me think step by step", - viewModel.uiState.value.messages[1].reasoningText, + viewModel.uiState.value.messages[1] + .reasoningText, ) // reasoning persisted to the entity (survives reload) val persisted = @@ -804,8 +806,16 @@ class ChatViewModelTest { viewModel.removeAttachment(1) advanceUntilIdle() assertEquals(2, viewModel.uiState.value.pendingAttachments.size) - assertEquals("uri0", viewModel.uiState.value.pendingAttachments[0].uri) - assertEquals("uri2", viewModel.uiState.value.pendingAttachments[1].uri) + assertEquals( + "uri0", + viewModel.uiState.value.pendingAttachments[0] + .uri, + ) + assertEquals( + "uri2", + viewModel.uiState.value.pendingAttachments[1] + .uri, + ) // 2. Fire invalid removals (out of bounds + negative) — must be no-ops. viewModel.removeAttachment(99) @@ -1290,8 +1300,10 @@ class ChatViewModelTest { // Graceful handling: the message is still sent even though the // attachment bytes couldn't be read (no crash, no lost message). val sent = - viewModel.uiState.value.messages.firstOrNull { it.id == sessionId } != null || - viewModel.uiState.value.messages.any { it.content == "Here is an image" } + viewModel.uiState.value.messages + .firstOrNull { it.id == sessionId } != null || + viewModel.uiState.value.messages + .any { it.content == "Here is an image" } assertTrue("Message should still be sent despite attachment read failure", sent) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/gateway/GatewayViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/gateway/GatewayViewModelTest.kt index 5c479b59..d1e510e9 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/gateway/GatewayViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/gateway/GatewayViewModelTest.kt @@ -88,7 +88,10 @@ class GatewayViewModelTest { assertFalse(viewModel.uiState.value.isLoading) assertNull(viewModel.uiState.value.status) - assertTrue(viewModel.uiState.value.errorMessage?.contains("500") == true) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("500") == true, + ) } @Test @@ -104,6 +107,9 @@ class GatewayViewModelTest { assertFalse(viewModel.uiState.value.isLoading) assertNull(viewModel.uiState.value.status) - assertTrue(viewModel.uiState.value.errorMessage?.contains("Network timeout") == true) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("Network timeout") == true, + ) } }