diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index c25d713e..59098ff7 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -28,7 +28,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -44,7 +43,6 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.local.AuthSessionState import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.theme.LocalHermesStatusColors @@ -134,14 +132,6 @@ private fun appEntryProvider( @Composable fun MainNavigation(sessionId: String? = null) { - val signInRequired by AuthSessionState.signInRequired.collectAsState() - LaunchedEffect(signInRequired) { - if (signInRequired) { - HermesWsClient.disconnect() - NavigationController.resetTo(AuthLoginScreen) - } - } - val token by AuthManager.tokenFlow.collectAsState() val hasToken = !token.isNullOrBlank() val startScreen: NavKey = if (hasToken) ChatScreen else LandingScreen diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index fe8db51d..3ea94ac8 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -2,7 +2,6 @@ package com.m57.hermescontrol import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import com.m57.hermescontrol.data.local.AuthSessionState /** * Central navigation controller with deduplication guard. @@ -59,24 +58,11 @@ object NavigationController { */ fun goBack(fallback: NavKey = ChatScreen) { val stack = backStack ?: return - val effectiveFallback = - if (AuthSessionState.signInRequired.value && (fallback == ChatScreen || isPrimaryScreen(fallback))) { - LandingScreen - } else { - fallback - } if (stack.size > 1) { stack.removeLastOrNull() - if (AuthSessionState.signInRequired.value && - stack.lastOrNull() != AuthLoginScreen && - stack.lastOrNull() != LandingScreen - ) { - stack.clear() - stack.add(LandingScreen) - } } else if (stack.size == 1) { stack.clear() - stack.add(effectiveFallback) + stack.add(fallback) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthSessionState.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthSessionState.kt deleted file mode 100644 index 0e9866d0..00000000 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthSessionState.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.m57.hermescontrol.data.local - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Single source of truth for app-wide auth session expiry latch. - * When a REST call returns 401 (AuthExpired), [requireSignIn] is invoked, - * triggering central navigation reset to [AuthLoginScreen] and WS disconnect. - */ -object AuthSessionState { - private val _signInRequired = MutableStateFlow(false) - val signInRequired: StateFlow = _signInRequired.asStateFlow() - - fun requireSignIn() { - _signInRequired.value = true - } - - fun markAuthenticated() { - _signInRequired.value = false - } - - fun clearSignInRequired() { - _signInRequired.value = false - } - - fun resetForTest() { - _signInRequired.value = false - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt index 856d11b9..6512ec99 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt @@ -1,6 +1,5 @@ package com.m57.hermescontrol.data.remote -import com.m57.hermescontrol.data.local.AuthSessionState import kotlinx.coroutines.delay import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -91,7 +90,6 @@ fun jitteredBackoff(baseMs: Long): Long { suspend inline fun safeApiCall( retries: Int = 2, - reportAuthExpiry: Boolean = true, crossinline call: suspend () -> Response, ): NetworkResult { var lastException: IOException? = null @@ -120,11 +118,7 @@ suspend inline fun safeApiCall( delay(backoff) continue } - val failure = NetworkResult.Failure(mapHttpError(code, errorBody)) - if (reportAuthExpiry && failure.error is NetworkError.AuthExpired) { - AuthSessionState.requireSignIn() - } - return failure + return NetworkResult.Failure(mapHttpError(code, errorBody)) } } catch (e: IOException) { lastException = e diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt index 05912eed..54a049a8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModel.kt @@ -8,7 +8,6 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.R import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.local.AuthSessionState import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.AuthPayloads import com.m57.hermescontrol.data.remote.CleartextPolicy @@ -81,20 +80,9 @@ class AuthLoginViewModel( init { loadLoggedInProfiles() - viewModelScope.launch { - AuthSessionState.signInRequired.collect { required -> - if (required) { - _uiState.update { it.copy(loggedInProfiles = emptyList()) } - } - } - } } fun loadLoggedInProfiles() { - if (AuthSessionState.signInRequired.value) { - _uiState.update { it.copy(loggedInProfiles = emptyList()) } - return - } val allProfiles = AuthManager.getConnectionProfiles() val loggedIn = allProfiles.filter { profile -> @@ -107,7 +95,6 @@ class AuthLoginViewModel( fun useExistingProfile(profileId: String) { AuthManager.setSelectedProfileId(profileId) ApiClient.rebuild() - AuthSessionState.markAuthenticated() HermesWsClient.connect() _uiState.update { it.copy(connectionSuccess = true) } } @@ -358,7 +345,6 @@ class AuthLoginViewModel( AuthManager.setWsAuthParam("ticket") } ApiClient.rebuild() - AuthSessionState.markAuthenticated() HermesWsClient.connect() _uiState.update { it.copy(isLoading = false, connectionSuccess = true) } } @@ -380,7 +366,7 @@ class AuthLoginViewModel( } val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), token) - val result = safeApiCall(reportAuthExpiry = false) { tempApi.getSessions() } + val result = safeApiCall { tempApi.getSessions() } return when (result) { is com.m57.hermescontrol.data.remote.NetworkResult.Success -> { 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 67d9d7af..b0ec848d 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 @@ -8,7 +8,6 @@ import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.local.AuthSessionState import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.ModelProvider @@ -1871,7 +1870,6 @@ class ChatViewModel( } fun reconnect() { - AuthSessionState.markAuthenticated() _uiState.update { it.copy( isLoading = true, @@ -1969,7 +1967,6 @@ class ChatViewModel( AuthManager.setWsAuthParam("ticket") AuthManager.setToken(ticket) - AuthSessionState.markAuthenticated() withContext(Dispatchers.Main) { onResult(true, null) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index 2ee90ac1..89ff8ee7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -8,7 +8,6 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.config.resolveBaseUrl import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.local.AuthSessionState import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.NetworkError @@ -116,7 +115,7 @@ class ConnectViewModel( val result = withContext(Dispatchers.IO) { val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), state.token) - safeApiCall(reportAuthExpiry = false) { tempApi.getStatus() } + safeApiCall { tempApi.getStatus() } } when (result) { is NetworkResult.Success -> { @@ -124,7 +123,6 @@ class ConnectViewModel( AuthManager.setToken(state.token) AuthManager.setBaseUrl(state.baseUrl) ApiClient.rebuild() - AuthSessionState.markAuthenticated() if (state.saveProfile) { val currentProfiles = AuthManager.getConnectionProfiles() diff --git a/app/src/test/java/com/m57/hermescontrol/AuthenticationRoutingTest.kt b/app/src/test/java/com/m57/hermescontrol/AuthenticationRoutingTest.kt deleted file mode 100644 index 6d510f8a..00000000 --- a/app/src/test/java/com/m57/hermescontrol/AuthenticationRoutingTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.m57.hermescontrol - -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavKey -import com.m57.hermescontrol.data.local.AuthSessionState -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class AuthenticationRoutingTest { - @Before - fun setup() { - AuthSessionState.resetForTest() - } - - @After - fun teardown() { - AuthSessionState.resetForTest() - NavigationController.backStack = null - } - - @Test - fun testExpiryPropagationState() { - assertFalse(AuthSessionState.signInRequired.value) - - AuthSessionState.requireSignIn() - assertTrue(AuthSessionState.signInRequired.value) - - AuthSessionState.markAuthenticated() - assertFalse(AuthSessionState.signInRequired.value) - } - - @Test - fun testGoBackFallbackGuardsChatWhenSignInRequired() { - val stack = NavBackStack(AuthLoginScreen) - NavigationController.backStack = stack - - AuthSessionState.requireSignIn() - assertTrue(AuthSessionState.signInRequired.value) - - // Attempting to go back from AuthLoginScreen when single element stack - NavigationController.goBack(fallback = ChatScreen) - - // Must fall back to LandingScreen, not ChatScreen - assertEquals(LandingScreen, stack.lastOrNull()) - } - - @Test - fun testGoBackClearsToLandingWhenSignInRequiredAndExposingProtectedScreen() { - val stack = NavBackStack(ChatScreen) - stack.add(AuthLoginScreen) - NavigationController.backStack = stack - - AuthSessionState.requireSignIn() - assertTrue(AuthSessionState.signInRequired.value) - - // Attempting to pop AuthLoginScreen when ChatScreen is underneath - NavigationController.goBack() - - // Must not expose ChatScreen - assertEquals(LandingScreen, stack.lastOrNull()) - } -} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkResultTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkResultTest.kt index cd17d755..85bec6bb 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkResultTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/NetworkResultTest.kt @@ -1,11 +1,9 @@ package com.m57.hermescontrol.data.remote -import com.m57.hermescontrol.data.local.AuthSessionState import kotlinx.coroutines.runBlocking import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import retrofit2.Response @@ -198,36 +196,4 @@ class NetworkResultTest { assertTrue(unknownError.message.contains("Unexpected error")) assertEquals(1, callCount) // Should not retry for unknown exceptions } - - @Test - fun testSafeApiCall_authExpired_triggersLatchWhenReportTrue() = - runBlocking { - AuthSessionState.resetForTest() - assertFalse(AuthSessionState.signInRequired.value) - - val result = - safeApiCall(reportAuthExpiry = true) { - Response.error(401, "Unauthorized".toResponseBody("text/plain".toMediaTypeOrNull())) - } - - assertTrue(result is NetworkResult.Failure) - assertTrue((result as NetworkResult.Failure).error is NetworkError.AuthExpired) - assertTrue(AuthSessionState.signInRequired.value) - } - - @Test - fun testSafeApiCall_authExpired_doesNotTriggerLatchWhenReportFalse() = - runBlocking { - AuthSessionState.resetForTest() - assertFalse(AuthSessionState.signInRequired.value) - - val result = - safeApiCall(reportAuthExpiry = false) { - Response.error(401, "Unauthorized".toResponseBody("text/plain".toMediaTypeOrNull())) - } - - assertTrue(result is NetworkResult.Failure) - assertTrue((result as NetworkResult.Failure).error is NetworkError.AuthExpired) - assertFalse(AuthSessionState.signInRequired.value) - } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt index b7ec6d2b..69f34d0c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/authlogin/AuthLoginViewModelTest.kt @@ -3,12 +3,9 @@ package com.m57.hermescontrol.ui.authlogin import android.app.Application import android.util.Log import com.m57.hermescontrol.R -import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.local.AuthSessionState import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.ServerEndpoint -import com.m57.hermescontrol.data.ws.HermesWsClient import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -22,7 +19,6 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.io.IOException @@ -156,33 +152,4 @@ class AuthLoginViewModelTest { viewModel.deriveAuthMode(authRequired = true, providers = listOf("weird")), ) } - - @Test - fun `loadLoggedInProfiles when signInRequired is true clears profile shortcuts`() { - AuthSessionState.requireSignIn() - every { AuthManager.getConnectionProfiles() } returns - listOf( - ConnectionProfile(id = "p1", name = "P1", baseUrl = "http://127.0.0.1"), - ) - every { AuthManager.getProfileToken("p1") } returns "token1" - - viewModel.loadLoggedInProfiles() - - assertTrue(viewModel.uiState.value.loggedInProfiles.isEmpty()) - AuthSessionState.resetForTest() - } - - @Test - fun `useExistingProfile marks session authenticated`() { - AuthSessionState.requireSignIn() - mockkObject(HermesWsClient) - every { AuthManager.setSelectedProfileId("p1") } returns Unit - every { HermesWsClient.connect() } returns Unit - - viewModel.useExistingProfile("p1") - - assertFalse(AuthSessionState.signInRequired.value) - assertTrue(viewModel.uiState.value.connectionSuccess) - AuthSessionState.resetForTest() - } }