From 04bb7c4325d6061273a5734e7e707eb667fd05b8 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:36:23 +0300 Subject: [PATCH] Fix/issue 470 stuck reconnecting (#495) * fix(ws): stop chat opening from racing the singleton into a stuck RECONNECTING Root cause: HermesWsClient is a global singleton connected once at login; every tab observes its single connectionStatus. ChatViewModel is recreated on every chat-tab open and its init calls connectWebSocket() -> wsClient.connect(). connect() only guarded the CONNECTED case, so when the socket had dropped (singleton in RECONNECTING with its own scheduled reconnect) opening chat spawned a SECOND socket on the same webSocket field. The orphaned socket's later onClosed set connected=false + re-armed RECONNECTING after the new socket connected, and currentBackoff kept resetting on each chat open, leaving the status stuck on RECONNECTING while every other tab (which never calls connect()) stayed healthy. Fix: - HermesWsClient.connect() now no-ops while CONNECTING/RECONNECTING (in-flight socket already pending) and while AUTH_EXPIRED (needs re-auth, not a loop). - ChatViewModel.connectWebSocket() mirrors the same terminal-state guard so the chat tab never disturbs an already-working or already-recovering connection. Behavior-preserving: happy path (CONNECTED) and genuine terminal states (DISCONNECTED/NO_NETWORK) still connect as before. * fix(#470): sync cookie store scope with selected profile ID Update AuthManager to switch CookieManager to the active server profile ID on startup and on runtime profile change. * fix(#470): prevent stale token cache and load initial cookie scope Pass active profile scope to CookieManager.initialize on startup and update setProfileToken to sync the in-memory cachedToken. * feat(chat): add re-login action and credentials popup to connection banner Add a Re-login button to the connection status banner and a dialog to prompt for credentials, allowing the user to refresh session cookies/tokens and reconnect the WebSocket. * fix(chat): address PR #495 review feedback * fix(chat): restore reconnect action in banner * fix: resolve composable invocation in ReloginDialog * fix(chat): enable re-login action during reconnecting state Add the Re-login button to the connection status banner when connectionStatus is RECONNECTING, allowing users to re-authenticate and resolve stuck reconnect loops. --- .../hermescontrol/data/local/AuthManager.kt | 41 ++++- .../data/remote/CookieManager.kt | 5 +- .../hermescontrol/data/ws/HermesWsClient.kt | 19 +++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 150 ++++++++++++++++-- .../hermescontrol/ui/chat/ChatViewModel.kt | 117 ++++++++++++++ app/src/main/res/values/strings.xml | 8 +- 6 files changed, 319 insertions(+), 21 deletions(-) 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 dd6bfa33..5cc55022 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 @@ -119,7 +119,9 @@ object AuthManager { // session-cookie prefs are passed as a Deferred so existing gated // sessions can be migrated on first load WITHOUT blocking startup. // The Deferred is created above, before this call. - CookieManager.initialize(context, prefsDeferred) + val initialProfileId = + store.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID + CookieManager.initialize(context, prefsDeferred, initialProfileId) scope.launch { store.stateFlow.collect { state -> @@ -128,6 +130,8 @@ object AuthManager { _useDynamicColorsFlow.value = state.useDynamicColors _themePresetFlow.value = state.themePreset _bottomNavDisplayModeFlow.value = state.bottomNavDisplayMode + // B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile. + syncCookieStoreForProfile(state.selectedProfileId) } } } @@ -266,13 +270,15 @@ object AuthManager { if (p.getBoolean(KEY_LEGACY_DEFAULT_MIGRATED, false)) return val legacyToken = p.getString(KEY_LEGACY_TOKEN, null) ensureDefaultProfile() - p.edit().apply { - if (!legacyToken.isNullOrBlank()) { - putString("token_$DEFAULT_PROFILE_ID", legacyToken) - } - remove(KEY_LEGACY_TOKEN) - putBoolean(KEY_LEGACY_DEFAULT_MIGRATED, true) - }.apply() + p + .edit() + .apply { + if (!legacyToken.isNullOrBlank()) { + putString("token_$DEFAULT_PROFILE_ID", legacyToken) + } + remove(KEY_LEGACY_TOKEN) + putBoolean(KEY_LEGACY_DEFAULT_MIGRATED, true) + }.apply() } // ── Pinned Models ──────────────────────────────────────────────────── @@ -291,6 +297,12 @@ object AuthManager { ) { requirePrefs().edit().putString("token_$profileId", token).apply() if (getSelectedProfileId() == profileId) { + // B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken + // to prevent stale tokens during ticket refresh + synchronized(this) { + cachedToken = token + tokenInitialized = true + } _tokenFlow.value = token } } @@ -306,6 +318,19 @@ object AuthManager { tokenInitialized = false } _tokenFlow.value = getToken() + // B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile. + syncCookieStoreForProfile(id) + } + + private fun normalizedProfileId(profileId: String?): String = + profileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID + + private fun syncCookieStoreForProfile(profileId: String?) { + if (!CookieManager.isInitialized()) return + val normalizedId = normalizedProfileId(profileId) + if (CookieManager.cookieJar.currentServer() != normalizedId) { + CookieManager.useStore(normalizedId) + } } // ── Token ──────────────────────────────────────────────────────────── 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 27e66ae1..35d7cadc 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 @@ -39,15 +39,16 @@ object CookieManager { fun initialize( context: Context, legacyPrefsDeferred: Deferred? = null, + initialServerId: String = PersistentCookieJar.DEFAULT_SERVER_ID, ) { if (jar != null) return synchronized(this) { if (jar != null) return val store = EncryptedCookieStore(context.applicationContext, legacyPrefsDeferred) jar = PersistentCookieJar(store, scope) - // Eagerly load the default scope off the caller thread so the + // Eagerly load the initial scope off the caller thread so the // first REST call is instant without blocking app startup. - scope.launch { jar!!.useStore(PersistentCookieJar.DEFAULT_SERVER_ID) } + scope.launch { jar!!.useStore(initialServerId) } } } 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 8c3eb446..dada7897 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 @@ -165,6 +165,25 @@ object HermesWsClient { Log.d(TAG, "Already connected — skipping") return } + // Guard against re-entrant connect() while a connection is already in + // flight. The singleton may be CONNECTING (mid handshake) or RECONNECTING + // (a scheduled reconnect is pending). Opening a second socket on the same + // `webSocket` field races the in-flight one and can leave the status + // stuck on RECONNECTING (e.g. the chat tab calls connect() on every open + // while the app-level reconnect is already running). Only start a fresh + // socket from a terminal state. + if (_connectionStatus.value == ConnectionStatus.CONNECTING || + _connectionStatus.value == ConnectionStatus.RECONNECTING + ) { + Log.d(TAG, "Connection already in flight (${_connectionStatus.value}) — skipping") + return + } + // AUTH_EXPIRED cannot be resolved by reconnecting alone — caller must + // re-authenticate. Leave the status as-is so the UI can surface sign-in. + if (_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) { + Log.d(TAG, "Connection is AUTH_EXPIRED — skipping reconnect; re-auth required") + return + } intentionalClose.set(false) currentBackoff = INITIAL_BACKOFF_MS _connectionStatus.value = ConnectionStatus.CONNECTING diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 1aa98405..e15c7851 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -109,7 +109,6 @@ import androidx.core.content.FileProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage -import com.m57.hermescontrol.AuthLoginScreen import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment @@ -148,6 +147,7 @@ fun ChatScreen( var inputText by rememberSaveable { mutableStateOf("") } var isListening by rememberSaveable { mutableStateOf(false) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } + var showReloginDialog by rememberSaveable { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } val isDark = isSystemInDarkTheme() val context = LocalContext.current @@ -362,7 +362,7 @@ fun ChatScreen( ChatTopBanner( connectionStatus = state.connectionStatus, onReconnect = viewModel::reconnect, - onAuthExpired = { NavigationController.navigateTo(AuthLoginScreen) }, + onReloginClick = { showReloginDialog = true }, ) Box( @@ -480,6 +480,15 @@ fun ChatScreen( onRemoveAttachment = viewModel::removeAttachment, ) } + + if (showReloginDialog) { + ReloginDialog( + onDismiss = { showReloginDialog = false }, + onRelogin = { username, password, onResult -> + viewModel.relogin(username, password, onResult) + }, + ) + } } } @@ -1340,7 +1349,7 @@ private fun ChatLifecycleEffects( private fun ChatTopBanner( connectionStatus: ConnectionStatus, onReconnect: () -> Unit, - onAuthExpired: () -> Unit, + onReloginClick: () -> Unit, ) { val isShown = connectionStatus != ConnectionStatus.CONNECTED && @@ -1384,9 +1393,30 @@ private fun ChatTopBanner( ) } when (connectionStatus) { - ConnectionStatus.DISCONNECTED, - ConnectionStatus.NO_NETWORK, - -> { + ConnectionStatus.DISCONNECTED -> { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = onReloginClick, + colors = + androidx.compose.material3.ButtonDefaults.textButtonColors( + contentColor = LocalHermesStatusColors.current.error, + ), + ) { + Text(stringResource(R.string.chat_action_relogin)) + } + TextButton( + onClick = onReconnect, + colors = + androidx.compose.material3.ButtonDefaults.textButtonColors( + contentColor = LocalHermesStatusColors.current.error, + ), + ) { + Text(stringResource(R.string.chat_action_reconnect)) + } + } + } + + ConnectionStatus.NO_NETWORK -> { TextButton( onClick = onReconnect, colors = @@ -1397,18 +1427,22 @@ private fun ChatTopBanner( Text(stringResource(R.string.chat_action_reconnect)) } } - ConnectionStatus.AUTH_EXPIRED -> { + + ConnectionStatus.RECONNECTING, + ConnectionStatus.AUTH_EXPIRED, + -> { TextButton( - onClick = onAuthExpired, + onClick = onReloginClick, colors = androidx.compose.material3.ButtonDefaults.textButtonColors( contentColor = LocalHermesStatusColors.current.error, ), ) { - Text(stringResource(R.string.chat_action_sign_in)) + Text(stringResource(R.string.chat_action_relogin)) } } - else -> { /* RECONNECTING: auto, no button */ } + + else -> {} } } } @@ -1627,3 +1661,99 @@ private fun BoxScope.ChatScrollToBottomFab( } } } + +@Composable +private fun ReloginDialog( + onDismiss: () -> Unit, + onRelogin: (String, String, (Boolean, String?) -> Unit) -> Unit, +) { + val context = LocalContext.current + val statusColors = LocalHermesStatusColors.current + var username by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = { if (!isLoading) onDismiss() }, + title = { Text(stringResource(R.string.chat_relogin_title)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = username, + onValueChange = { + username = it + errorMessage = null + }, + label = { Text(stringResource(R.string.chat_relogin_username)) }, + singleLine = true, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = password, + onValueChange = { + password = it + errorMessage = null + }, + label = { Text(stringResource(R.string.chat_relogin_password)) }, + singleLine = true, + enabled = !isLoading, + visualTransformation = + androidx.compose.ui.text.input + .PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + if (errorMessage != null) { + Text( + text = errorMessage!!, + color = statusColors.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + TextButton( + enabled = !isLoading, + onClick = { + if (username.isBlank() || password.isBlank()) { + errorMessage = context.getString(R.string.chat_relogin_error_empty) + return@TextButton + } + isLoading = true + errorMessage = null + onRelogin(username, password) { success, error -> + isLoading = false + if (success) { + onDismiss() + } else { + errorMessage = error ?: "Unknown error" + } + } + }, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = statusColors.info, + ) + } else { + Text(stringResource(R.string.chat_relogin_submit)) + } + } + }, + dismissButton = { + TextButton( + enabled = !isLoading, + onClick = onDismiss, + ) { + Text(stringResource(R.string.chat_relogin_cancel)) + } + }, + ) +} 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 f0caa339..01d12811 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 @@ -34,6 +34,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.decodeFromJsonElement +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject import java.util.concurrent.ConcurrentHashMap private const val TAG = "ChatViewModel" @@ -195,6 +199,20 @@ class ChatViewModel( private fun connectWebSocket(setLoading: Boolean = false) { val token = AuthManager.getToken() ?: return + // Don't disturb an already-working (or already-recovering) connection. + // HermesWsClient is a global singleton shared by every tab; the chat tab + // is recreated on every open, so calling connect() here must be a no-op + // unless the singleton is in a terminal state. Re-entering connect() while + // it is CONNECTING/RECONNECTING races the in-flight socket and can leave + // the status stuck on RECONNECTING (see HermesWsClient.connect). + val status = wsClient.connectionStatus.value + if (status == ConnectionStatus.CONNECTING || + status == ConnectionStatus.RECONNECTING || + status == ConnectionStatus.AUTH_EXPIRED + ) { + return + } + if (setLoading) { _uiState.update { it.copy(isLoading = true) } } @@ -1079,6 +1097,105 @@ class ChatViewModel( } } + fun relogin( + username: String, + password: String, + onResult: (Boolean, String?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val host = AuthManager.getHost() + val port = AuthManager.getPort() + val baseUrl = "http://$host:$port" + val jsonMediaType = "application/json; charset=utf-8".toMediaType() + val jsonBody = + JSONObject() + .put("provider", "basic") + .put("username", username) + .put("password", password) + .put("next", "") + .toString() + + try { + val loginClient = + com.m57.hermescontrol.data.remote.OkHttpProvider.probe + .newBuilder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + val loginReq = + Request + .Builder() + .url("$baseUrl/auth/password-login") + .header("Content-Type", "application/json") + .post(jsonBody.toRequestBody(jsonMediaType)) + .build() + loginClient.newCall(loginReq).execute().use { loginResp -> + if (!loginResp.isSuccessful) { + val msg = + when (loginResp.code) { + 401 -> "Invalid username or password (401)" + 403 -> "Forbidden (403)" + else -> "HTTP error code: ${loginResp.code}" + } + withContext(Dispatchers.Main) { + onResult(false, msg) + } + return@launch + } + } + + val ticketClient = + com.m57.hermescontrol.data.remote.OkHttpProvider.base + .newBuilder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + val ticketReq = + Request + .Builder() + .url("$baseUrl/api/auth/ws-ticket") + .post("{}".toRequestBody(jsonMediaType)) + .build() + ticketClient.newCall(ticketReq).execute().use { ticketResp -> + if (!ticketResp.isSuccessful) { + withContext(Dispatchers.Main) { + onResult(false, "Failed to mint WS ticket: HTTP ${ticketResp.code}") + } + return@launch + } + + val body = ticketResp.body?.string().orEmpty() + val ticket = JSONObject(body).optString("ticket").takeIf { it.isNotBlank() } + + if (ticket.isNullOrBlank()) { + withContext(Dispatchers.Main) { + onResult(false, "Invalid ticket returned from server") + } + return@launch + } + + AuthManager.setWsAuthParam("ticket") + AuthManager.setToken(ticket) + + withContext(Dispatchers.Main) { + onResult(true, null) + reconnect() + } + } + } catch (e: java.io.IOException) { + withContext(Dispatchers.Main) { + onResult(false, "Connection failed: ${e.message}") + } + } catch (e: org.json.JSONException) { + withContext(Dispatchers.Main) { + onResult(false, "Connection failed: ${e.message}") + } + } + } + } + private fun addSystemMessage( text: String, persist: Boolean = false, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dc0d12b5..1642bea3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,7 +82,13 @@ Search Close search Reconnect - Sign in again + Re-login + Re-login to Hermes + Username + Password + Login + Cancel + Username and password cannot be empty Ready to chat Send a message to start a conversation Connecting to Hermes\u2026