feat(profiles): ProfileContext single source of truth + per-server token fallback (phase 1) (#821)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-06 15:06:55 +03:00
1 parent 75ac0c4790
commit 398268d105
4 files changed
+163 -18

No files matched your search

@@ -25,8 +25,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -77,6 +80,30 @@ object AuthManager {
private val _selectedProfileFlow = MutableStateFlow<String?>(null) private val _selectedProfileFlow = MutableStateFlow<String?>(null)
val selectedProfileFlow: StateFlow<String?> = _selectedProfileFlow.asStateFlow() val selectedProfileFlow: StateFlow<String?> = _selectedProfileFlow.asStateFlow()
private val _baseUrlFlow = MutableStateFlow("")
val baseUrlFlow: StateFlow<String> = _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<ProfileContext?> =
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. * Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate(). * Call this once from Application.onCreate() or MainActivity.onCreate().
@@ -103,6 +130,7 @@ object AuthManager {
val store = ServerStore(dataStore, scope) val store = ServerStore(dataStore, scope)
_serverStore = store _serverStore = store
_selectedProfileFlow.value = store.getLatestState().selectedProfileId _selectedProfileFlow.value = store.getLatestState().selectedProfileId
_baseUrlFlow.value = store.getLatestState().resolvedBaseUrl
if (prefsDeferred == null) { if (prefsDeferred == null) {
prefsDeferred = prefsDeferred =
@@ -394,8 +422,7 @@ object AuthManager {
if (tokenInitialized) return cachedToken if (tokenInitialized) return cachedToken
synchronized(this) { synchronized(this) {
if (tokenInitialized) return cachedToken if (tokenInitialized) return cachedToken
val selectedId = getSelectedProfileId() val token = resolveConnectionToken(getSelectedProfileId(), ::getProfileToken)
val token = if (selectedId != null) getProfileToken(selectedId) else null
cachedToken = token cachedToken = token
tokenInitialized = true tokenInitialized = true
return token return token
@@ -403,12 +430,24 @@ object AuthManager {
} }
private fun getTokenInternal(p: SharedPreferences): String? { private fun getTokenInternal(p: SharedPreferences): String? {
val selectedId = serverStore.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } val selectedId = serverStore.getLatestState().selectedProfileId
return if (selectedId != null) { return resolveConnectionToken(selectedId) { id -> p.getString("token_$id", null) }
p.getString("token_$selectedId", null)
} else {
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?) { fun setToken(token: String?) {
@@ -444,6 +483,7 @@ object AuthManager {
CleartextPolicy.ALLOW_WITH_WARNING, CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl ).baseUrl
.toString() .toString()
_baseUrlFlow.value = normalized
val selectedId = val selectedId =
getSelectedProfileId() ?: run { getSelectedProfileId() ?: run {
ensureDefaultSelected() ensureDefaultSelected()
@@ -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
}
@@ -199,12 +199,13 @@ class AuthManagerTest {
} }
@Test @Test
fun testGetToken_returnsNullWhenProfileHasNoToken() { fun testGetToken_profileWithoutToken_fallsBackToDefault() {
val profile1 = ConnectionProfile(id = "prof-1", name = "Profile 1", baseUrl = "http://127.0.0.1:9119/") val profile1 = ConnectionProfile(id = "prof-1", name = "Profile 1", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile1)) AuthManager.saveConnectionProfiles(listOf(profile1))
every { mockPrefs.getString("token_prof-1", null) } returns null 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") AuthManager.setSelectedProfileId("prof-1")
assertNull(AuthManager.getToken()) assertEquals("connection-token", AuthManager.getToken())
} }
@Test @Test
@@ -325,40 +326,50 @@ class AuthManagerTest {
} }
@Test @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 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 profileB = ConnectionProfile(id = "prof-b", name = "Profile B", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA, profileB)) 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 every { mockPrefs.getString("token_prof-a", null) } returns null
AuthManager.setSelectedProfileId("prof-a") 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 every { mockPrefs.getString("token_prof-b", null) } returns null
AuthManager.setSelectedProfileId("prof-b") 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()) assertNull(AuthManager.getToken())
} }
@Test @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 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 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/") val profileC = ConnectionProfile(id = "prof-c", name = "Profile C", baseUrl = "http://127.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profileA, profileB, profileC)) 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" every { mockPrefs.getString("token_prof-a", null) } returns "token-a"
AuthManager.setSelectedProfileId("prof-a") AuthManager.setSelectedProfileId("prof-a")
assertEquals("token-a", AuthManager.getToken()) 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 every { mockPrefs.getString("token_prof-b", null) } returns null
AuthManager.setSelectedProfileId("prof-b") 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" every { mockPrefs.getString("token_prof-c", null) } returns "token-c"
AuthManager.setSelectedProfileId("prof-c") AuthManager.setSelectedProfileId("prof-c")
assertEquals("token-c", AuthManager.getToken()) assertEquals("token-c", AuthManager.getToken())
@@ -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)
}
}