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