This reverts commit a5baf41545.
This commit is contained in:
10 files changed
+4
-217
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean> = _signInRequired.asStateFlow()
|
||||
|
||||
fun requireSignIn() {
|
||||
_signInRequired.value = true
|
||||
}
|
||||
|
||||
fun markAuthenticated() {
|
||||
_signInRequired.value = false
|
||||
}
|
||||
|
||||
fun clearSignInRequired() {
|
||||
_signInRequired.value = false
|
||||
}
|
||||
|
||||
fun resetForTest() {
|
||||
_signInRequired.value = false
|
||||
}
|
||||
}
|
||||
@@ -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 <reified T> safeApiCall(
|
||||
retries: Int = 2,
|
||||
reportAuthExpiry: Boolean = true,
|
||||
crossinline call: suspend () -> Response<T>,
|
||||
): NetworkResult<T> {
|
||||
var lastException: IOException? = null
|
||||
@@ -120,11 +118,7 @@ suspend inline fun <reified T> 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
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<NavKey>(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<NavKey>(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())
|
||||
}
|
||||
}
|
||||
@@ -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<String>(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<String>(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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user