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:
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user