diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 23d28756..b2865ac8 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -382,6 +382,11 @@ object AuthManager { ActiveSessionHolder.clear() } serverStore.update { it.copy(selectedProfileId = id) } + // Keep contextFlow truthful: the base URL resolves per selected + // profile, so a connection-profile switch must re-emit the NEW + // server's URL (previously stale — reactive consumers saw the old + // server's URL after a switch). + _baseUrlFlow.value = serverStore.getLatestState().resolvedBaseUrl _selectedProfileFlow.value = id synchronized(this) { tokenInitialized = false @@ -416,7 +421,17 @@ object AuthManager { private fun normalizedProfileId(profileId: String?): String = profileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID - private suspend fun syncCookieStoreForProfile(profileId: String?) { + /** + * Swaps the cookie jar's ACTIVE store to the given profile's. Idempotent + * (no-op when the store is already active). Suspends because switching + * store reads/writes the encrypted prefs. + * + * Internal so the switch coordinator can AWAIT the swap before re-dialing + * the WebSocket — a dial that races it mints the WS ticket with the + * PREVIOUS server's cookie → 401 → dead socket (split-brain follow-up, + * reproduced live 2026-08-12). + */ + internal suspend fun syncCookieStoreForProfile(profileId: String?) { if (!CookieManager.isInitialized()) return val normalizedId = normalizedProfileId(profileId) if (CookieManager.cookieJar.currentServer() != normalizedId) { @@ -456,6 +471,7 @@ object AuthManager { appScope = null } _activeProfileId.value = null + _baseUrlFlow.value = "" } fun getToken(): String? { diff --git a/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt b/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt index efa9022a..7fe39ad8 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinator.kt @@ -34,6 +34,9 @@ object ProfileSwitchCoordinator { private val _switched = MutableSharedFlow(extraBufferCapacity = 1) val switched: SharedFlow = _switched.asSharedFlow() + private val _connectionSwitched = MutableSharedFlow(extraBufferCapacity = 1) + val connectionSwitched: SharedFlow = _connectionSwitched.asSharedFlow() + suspend fun switchProfile(name: String): NetworkResult { val result = withContext(Dispatchers.IO) { @@ -53,4 +56,40 @@ object ProfileSwitchCoordinator { } return result } + + /** + * Switches the CONNECTION profile — which server the app talks to (e.g. + * LAN "default" vs a Tailscale host). Unlike [switchProfile] (which only + * re-scopes the SERVER-side Hermes profile over the same socket), this + * re-points Retrofit AND re-dials the WebSocket, because the socket stays + * glued to the old server otherwise: after a switch every REST tab talks + * to the new server while chat keeps streaming from the old gateway + * (split-brain reproduced live 2026-08-12 on the hyari emulator). + * + * Order matters: + * 1. Persist the LOCAL selection — the token cache, cookie scope and + * [AuthManager.contextFlow] re-home to the new profile. + * 2. Rebuild Retrofit so REST targets the new server. + * 3. Emit [connectionSwitched] BEFORE the socket re-dial, so chat wipes + * its stale conversation first; the re-dialed socket then delivers + * gateway.ready → handleGatewayReady auto-creates a FRESH session on + * the new server (desktop requestFreshSession parity). + * 4. Re-dial the WebSocket off the main thread (the ticket mint does + * blocking I/O — NetworkOnMainThreadException otherwise). + */ + suspend fun switchConnectionProfile(profileId: String?) { + AuthManager.setSelectedProfileId(profileId) + ApiClient.rebuild() + _connectionSwitched.emit(profileId.orEmpty()) + withContext(Dispatchers.IO) { + // The WS ticket mint reads the cookie jar's ACTIVE store; the + // selection change swaps that store asynchronously, so a dial + // that races it mints with the PREVIOUS server's cookie → 401 → + // aborted socket with no retry. Await the swap before dialing + // (idempotent no-op when it already landed). + AuthManager.syncCookieStoreForProfile(profileId) + HermesWsClient.disconnect() + HermesWsClient.connect() + } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 0c88a952..3b5ef0f7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -576,6 +576,16 @@ class ChatViewModel( resetSessionState(sessionId = null, title = "Hermes", isLoading = true) } } + // Same wipe when the CONNECTION profile changes (different server): + // without it, gateway.ready on the re-dialed socket tries to resume + // the OLD server's session on the NEW server and fails (split-brain + // after connection-profile switch, reproduced live 2026-08-12). + viewModelScope.launch { + ProfileSwitchCoordinator.connectionSwitched + .collect { _ -> + resetSessionState(sessionId = null, title = "Hermes", isLoading = true) + } + } // Slash-command usage ranking (issue #865): mirror the local usage // counts into state so the autocomplete can surface most-used // commands first. Best-effort — the store never throws. diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 14b5eeda..400d71b8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.ServerEndpoint import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset @@ -103,9 +104,13 @@ class SettingsViewModel( } fun selectProfile(profileId: String?) { - AuthManager.setSelectedProfileId(profileId) - viewModelScope.launch(ioDispatcher) { loadSettings() } - ApiClient.rebuild() + viewModelScope.launch(ioDispatcher) { + // The coordinator re-homes EVERYTHING: selection + Retrofit + + // WebSocket re-dial, so chat doesn't stay glued to the previous + // server (issue: split-brain after connection-profile switch). + ProfileSwitchCoordinator.switchConnectionProfile(profileId) + loadSettings() + } } fun deleteProfile(profileId: String) { @@ -114,14 +119,25 @@ class SettingsViewModel( AuthManager.setProfileToken(profileId, null) // Never leave selection null (issue #478): if the deleted profile was selected, // fall back to the default profile instead of clearing selection. - if (AuthManager.getSelectedProfileId() == profileId) { - if (updatedProfiles.none { it.id == AuthManager.DEFAULT_PROFILE_ID }) { - AuthManager.ensureDefaultProfile() + val reselectDefault = + if (AuthManager.getSelectedProfileId() == profileId) { + if (updatedProfiles.none { it.id == AuthManager.DEFAULT_PROFILE_ID }) { + AuthManager.ensureDefaultProfile() + } + true + } else { + false } - AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) + viewModelScope.launch(ioDispatcher) { + if (reselectDefault) { + // Same re-home as a manual switch: the WebSocket must leave + // the deleted server too, or chat keeps its stale socket. + ProfileSwitchCoordinator.switchConnectionProfile(AuthManager.DEFAULT_PROFILE_ID) + } else { + ApiClient.rebuild() + } + loadSettings() } - viewModelScope.launch(ioDispatcher) { loadSettings() } - ApiClient.rebuild() } // ── Profile add/edit dialog ────────────────────────────────────────── diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt index ddf67591..7b5ac117 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt @@ -141,6 +141,32 @@ class AuthManagerTest { assertEquals(9090, AuthManager.getPort()) } + @Test + fun testSetSelectedProfileId_refreshesBaseUrlFlow() { + AuthManager.ensureDefaultProfile() + AuthManager.setBaseUrl("http://10.0.0.1:9119/") + AuthManager.saveConnectionProfiles( + listOf( + ConnectionProfile( + id = AuthManager.DEFAULT_PROFILE_ID, + name = "Default", + baseUrl = "http://10.0.0.1:9119/", + ), + ConnectionProfile( + id = "prof-2", + name = "Home", + baseUrl = "http://10.0.0.2:9220/", + ), + ), + ) + + AuthManager.setSelectedProfileId("prof-2") + + // contextFlow consumers must see the NEW server's URL after a + // connection-profile switch, not the previous one (split-brain fix). + assertEquals("http://10.0.0.2:9220/", AuthManager.baseUrlFlow.value) + } + @Test fun testGetAndSetAutoReconnect() { AuthManager.setAutoReconnect(false) diff --git a/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt index 27ef0ade..1dc9f516 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/session/ProfileSwitchCoordinatorTest.kt @@ -113,5 +113,48 @@ class ProfileSwitchCoordinatorTest { verify(exactly = 0) { HermesWsClient.connect() } } + @Test + fun `connection switch re-homes selection retrofit and socket in order`() = + runTest { + every { AuthManager.setSelectedProfileId(any()) } returns Unit + every { ApiClient.rebuild() } returns Unit + coEvery { AuthManager.syncCookieStoreForProfile(any()) } returns Unit + + ProfileSwitchCoordinator.switchConnectionProfile("prof-2") + + // Load-bearing order: selection persists FIRST (token cache + + // cookie scope follow), Retrofit re-points, the cookie store swap + // is AWAITED (a racing dial mints the ticket with the previous + // server's cookie → 401 → dead socket), then the socket re-dials + // — so chat's wipe (via the broadcast) lands before the new + // gateway's gateway.ready auto-creates the fresh session. + coVerify(ordering = Ordering.SEQUENCE) { + AuthManager.setSelectedProfileId("prof-2") + ApiClient.rebuild() + AuthManager.syncCookieStoreForProfile("prof-2") + HermesWsClient.disconnect() + HermesWsClient.connect() + } + } + + @Test + fun `connection switch broadcasts so chat wipes before the re-dial`() = + runTest { + every { AuthManager.setSelectedProfileId(any()) } returns Unit + every { ApiClient.rebuild() } returns Unit + coEvery { AuthManager.syncCookieStoreForProfile(any()) } returns Unit + // Subscribe BEFORE the switch — exactly how ChatViewModel does it. + val received = Channel(Channel.UNLIMITED) + backgroundScope.launch { + ProfileSwitchCoordinator.connectionSwitched.collect { received.send(it) } + } + runCurrent() + + ProfileSwitchCoordinator.switchConnectionProfile("prof-2") + runCurrent() + + assertEquals("prof-2", received.tryReceive().getOrNull()) + } + private fun errorResponse(code: Int): Response = Response.error(code, "{}".toResponseBody(null)) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index d8ceaff4..941526d8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -106,6 +106,7 @@ class ChatUpdateCommandTest { // collectors park harmlessly. mockkObject(ProfileSwitchCoordinator) every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow() + every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow() every { HermesWsClient.send(any(), any(), any()) } answers { reqCount++ 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 e86717f3..68313689 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 @@ -126,6 +126,7 @@ class ChatViewModelTest { every { AuthManager.getSelectedProfileId() } returns null mockkObject(ProfileSwitchCoordinator) every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow + every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow() every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isAutoReconnect() } returns false diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 7d4b367f..0d3343b8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -84,6 +84,7 @@ class SlashCommandDispatchRpcTest { // pattern) so the collectors park harmlessly. mockkObject(ProfileSwitchCoordinator) every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow() + every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow() app = mockk(relaxed = true) fakeRepo = FakeChatPersistenceRepository() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index 9c02a32a..3b3e94c6 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -3,8 +3,11 @@ package com.m57.hermescontrol.ui.settings import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkAll @@ -84,7 +87,15 @@ class SettingsViewModelTest { every { AuthManager.getProfileToken(any()) } returns null every { AuthManager.ensureDefaultProfile() } returns Unit - // Ensure ApiClient.rebuild() is stubbed (used by selectProfile) + // selectProfile/deleteProfile route the switch through the coordinator + // (selection + Retrofit + WebSocket re-dial). Mirror the selection + // side-effect like the real coordinator's setSelectedProfileId does, + // so loadSettings() observes the new selection. + mockkObject(ProfileSwitchCoordinator) + coEvery { ProfileSwitchCoordinator.switchConnectionProfile(any()) } answers { + storedSelectedProfileId = firstArg() + Unit + } every { ApiClient.rebuild() } returns Unit } @@ -158,8 +169,7 @@ class SettingsViewModelTest { viewModel.selectProfile("prof-2") testDispatcher.scheduler.advanceUntilIdle() - verify { AuthManager.setSelectedProfileId("prof-2") } - verify { ApiClient.rebuild() } + coVerify { ProfileSwitchCoordinator.switchConnectionProfile("prof-2") } assertEquals("prof-2", viewModel.uiState.value.selectedProfileId) assertEquals("Home", viewModel.uiState.value.renameProfileName) } @@ -174,8 +184,7 @@ class SettingsViewModelTest { viewModel.selectProfile(AuthManager.DEFAULT_PROFILE_ID) testDispatcher.scheduler.advanceUntilIdle() - verify { AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) } - verify { ApiClient.rebuild() } + coVerify { ProfileSwitchCoordinator.switchConnectionProfile(AuthManager.DEFAULT_PROFILE_ID) } assertEquals(AuthManager.DEFAULT_PROFILE_ID, viewModel.uiState.value.selectedProfileId) } @@ -187,11 +196,13 @@ class SettingsViewModelTest { val viewModel = createViewModel() viewModel.deleteProfile("prof-1") + testDispatcher.scheduler.advanceUntilIdle() verify { AuthManager.saveConnectionProfiles(any()) } verify { AuthManager.setProfileToken("prof-1", null) } - verify { AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) } - verify { ApiClient.rebuild() } + // The deleted profile was selected — the fallback switch re-homes + // everything (selection + Retrofit + WebSocket) to default. + coVerify { ProfileSwitchCoordinator.switchConnectionProfile(AuthManager.DEFAULT_PROFILE_ID) } } @Test @@ -202,11 +213,13 @@ class SettingsViewModelTest { val viewModel = createViewModel() viewModel.deleteProfile("prof-2") + testDispatcher.scheduler.advanceUntilIdle() verify { AuthManager.saveConnectionProfiles(any()) } verify { AuthManager.setProfileToken("prof-2", null) } // Selected profile (prof-1) was NOT deleted — should NOT change selection verify(exactly = 0) { AuthManager.setSelectedProfileId(any()) } + coVerify(exactly = 0) { ProfileSwitchCoordinator.switchConnectionProfile(any()) } verify { ApiClient.rebuild() } }