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 2354dc9e..4f482d16 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 @@ -25,8 +25,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -77,6 +80,30 @@ object AuthManager { private val _selectedProfileFlow = MutableStateFlow(null) val selectedProfileFlow: StateFlow = _selectedProfileFlow.asStateFlow() + private val _baseUrlFlow = MutableStateFlow("") + val baseUrlFlow: StateFlow = _baseUrlFlow.asStateFlow() + + private val contextScope = CoroutineScope(Dispatchers.Default) + + /** + * The single source of truth for connection state: server URL + token + + * selected profile. Emits on login, logout, profile switch, or URL change, + * so reactive consumers (screens, switch coordinator) re-home off ONE + * flow instead of tracking the pieces separately. + */ + val contextFlow: StateFlow = + combine(tokenFlow, baseUrlFlow, selectedProfileFlow) { token, baseUrl, profileId -> + if (baseUrl.isBlank()) { + null + } else { + ProfileContext(baseUrl = baseUrl, token = token, profileId = profileId) + } + }.stateIn( + scope = contextScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + /** * Initialise the encrypted preferences. * Call this once from Application.onCreate() or MainActivity.onCreate(). @@ -103,6 +130,7 @@ object AuthManager { val store = ServerStore(dataStore, scope) _serverStore = store _selectedProfileFlow.value = store.getLatestState().selectedProfileId + _baseUrlFlow.value = store.getLatestState().resolvedBaseUrl if (prefsDeferred == null) { prefsDeferred = @@ -394,8 +422,7 @@ object AuthManager { if (tokenInitialized) return cachedToken synchronized(this) { if (tokenInitialized) return cachedToken - val selectedId = getSelectedProfileId() - val token = if (selectedId != null) getProfileToken(selectedId) else null + val token = resolveConnectionToken(getSelectedProfileId(), ::getProfileToken) cachedToken = token tokenInitialized = true return token @@ -403,12 +430,24 @@ object AuthManager { } private fun getTokenInternal(p: SharedPreferences): String? { - val selectedId = serverStore.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } - return if (selectedId != null) { - p.getString("token_$selectedId", null) - } else { - null - } + val selectedId = serverStore.getLatestState().selectedProfileId + return resolveConnectionToken(selectedId) { id -> p.getString("token_$id", null) } + } + + /** + * Per-server token semantics: a profile that has no token of its own + * inherits the connection (default) token — same dashboard = same auth. + * This is what makes profile switching never require a re-login, and it + * is restart-safe (the fallback applies on every resolution, not just + * at switch time). Profiles with their own token (a different server + * connection) keep it untouched. + */ + internal fun resolveConnectionToken( + selectedId: String?, + tokenFor: (String) -> String?, + ): String? { + val id = selectedId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID + return tokenFor(id) ?: tokenFor(DEFAULT_PROFILE_ID) } fun setToken(token: String?) { @@ -444,6 +483,7 @@ object AuthManager { CleartextPolicy.ALLOW_WITH_WARNING, ).baseUrl .toString() + _baseUrlFlow.value = normalized val selectedId = getSelectedProfileId() ?: run { ensureDefaultSelected() diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ProfileContext.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ProfileContext.kt new file mode 100644 index 00000000..46bb7b88 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ProfileContext.kt @@ -0,0 +1,19 @@ +package com.m57.hermescontrol.data.local + +/** + * The app's single source of truth for "where am I + who am I". + * + * Emitted by [AuthManager.contextFlow] whenever the connected server + * ([baseUrl]), its [token], or the selected [profileId] changes — so + * screens and the switch coordinator can re-home reactively instead of + * each layer tracking its own slice of connection state. + */ +data class ProfileContext( + val baseUrl: String, + val token: String?, + val profileId: String?, +) { + /** True when a non-default profile is explicitly selected. */ + val isProfileScoped: Boolean + get() = profileId != null && profileId != AuthManager.DEFAULT_PROFILE_ID +} 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 655e4b1e..94ab886e 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 @@ -199,12 +199,13 @@ class AuthManagerTest { } @Test - fun testGetToken_returnsNullWhenProfileHasNoToken() { + fun testGetToken_profileWithoutToken_fallsBackToDefault() { val profile1 = ConnectionProfile(id = "prof-1", name = "Profile 1", baseUrl = "http://127.0.0.1:9119/") AuthManager.saveConnectionProfiles(listOf(profile1)) every { mockPrefs.getString("token_prof-1", null) } returns null + every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns "connection-token" AuthManager.setSelectedProfileId("prof-1") - assertNull(AuthManager.getToken()) + assertEquals("connection-token", AuthManager.getToken()) } @Test @@ -325,40 +326,50 @@ class AuthManagerTest { } @Test - fun testGetToken_profileWithNoTokenReturnsNull() { + fun testGetToken_profileWithoutToken_fallsBackToDefaultToken() { val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/") val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/") AuthManager.saveConnectionProfiles(listOf(profileA, profileB)) - // Profile A is selected but has no token → null (no global fallback) + // The default profile carries the connection token. + every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns "conn-token" + + // Profile A is selected but has no token → inherits the connection token every { mockPrefs.getString("token_prof-a", null) } returns null AuthManager.setSelectedProfileId("prof-a") - assertNull(AuthManager.getToken()) + assertEquals("conn-token", AuthManager.getToken()) - // Switch to Profile B, which also has no token → still null + // Profile B also has no token → same fallback every { mockPrefs.getString("token_prof-b", null) } returns null AuthManager.setSelectedProfileId("prof-b") + assertEquals("conn-token", AuthManager.getToken()) + + // When even the default has no token → null + every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns null + AuthManager.setSelectedProfileId("prof-a") assertNull(AuthManager.getToken()) } @Test - fun testGetToken_selectiveProfileTokens_someNull() { + fun testGetToken_selectiveProfileTokens_fallsBackWhenMissing() { val profileA = ConnectionProfile(id = "prof-a", name = "Profile A", baseUrl = "http://127.0.0.1:9119/") val profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/") val profileC = ConnectionProfile(id = "prof-c", name = "Profile C", baseUrl = "http://127.0.0.1:9119/") AuthManager.saveConnectionProfiles(listOf(profileA, profileB, profileC)) - // Profile A has a token + every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns "conn-token" + + // Profile A has a token → its own wins every { mockPrefs.getString("token_prof-a", null) } returns "token-a" AuthManager.setSelectedProfileId("prof-a") assertEquals("token-a", AuthManager.getToken()) - // Profile B has no token → null + // Profile B has no token → inherits the connection (default) token every { mockPrefs.getString("token_prof-b", null) } returns null AuthManager.setSelectedProfileId("prof-b") - assertNull(AuthManager.getToken()) + assertEquals("conn-token", AuthManager.getToken()) - // Profile C has its own token too + // Profile C has its own token too → its own wins every { mockPrefs.getString("token_prof-c", null) } returns "token-c" AuthManager.setSelectedProfileId("prof-c") assertEquals("token-c", AuthManager.getToken()) diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTokenTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTokenTest.kt new file mode 100644 index 00000000..45d3d980 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTokenTest.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol.data.local + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Tests for the per-server token resolution that makes profile switching + * restart-safe without a re-login. + * + * Rule: a profile without its own token inherits the connection (default) + * token — same dashboard = same auth. + */ +class AuthManagerTokenTest { + @Test + fun `selected profile with own token keeps it`() { + val token = AuthManager.resolveConnectionToken("meow") { id -> if (id == "meow") "tok-meow" else null } + assertEquals("tok-meow", token) + } + + @Test + fun `selected profile without own token inherits default token`() { + val token = + AuthManager.resolveConnectionToken("meow") { id -> + if (id == AuthManager.DEFAULT_PROFILE_ID) "tok-conn" else null + } + assertEquals("tok-conn", token) + } + + @Test + fun `default profile uses its own token`() { + val token = + AuthManager.resolveConnectionToken(AuthManager.DEFAULT_PROFILE_ID) { id -> + if (id == AuthManager.DEFAULT_PROFILE_ID) "tok-conn" else null + } + assertEquals("tok-conn", token) + } + + @Test + fun `null selection falls back to default token`() { + val token = + AuthManager.resolveConnectionToken(null) { id -> + if (id == AuthManager.DEFAULT_PROFILE_ID) "tok-conn" else null + } + assertEquals("tok-conn", token) + } + + @Test + fun `blank selection treated as default`() { + val token = + AuthManager.resolveConnectionToken(" ") { id -> + if (id == AuthManager.DEFAULT_PROFILE_ID) "tok-conn" else null + } + assertEquals("tok-conn", token) + } + + @Test + fun `no tokens anywhere returns null`() { + assertNull(AuthManager.resolveConnectionToken("meow") { null }) + assertNull(AuthManager.resolveConnectionToken(null) { null }) + } + + @Test + fun `own token beats default token when both exist`() { + val token = + AuthManager.resolveConnectionToken("meow") { id -> + when (id) { + "meow" -> "tok-meow" + AuthManager.DEFAULT_PROFILE_ID -> "tok-conn" + else -> null + } + } + assertEquals("tok-meow", token) + } +}