feat(android): Phase 17b — persist user identity across cold restart
Closes the last known MVP gap: after a fresh app launch with a
persisted session cookie, the Settings screen now shows the actual
username instead of going blank until the next sign-in.
Schema bump: AppDatabase version 1 → 2 to add userJson column on
auth_session. fallbackToDestructiveMigration already in
DatabaseModule handles the upgrade — users lose the cookie + cached
content on first launch after update, the sync controller refills,
and the next sign-in repopulates the user row. Acceptable pre-v1.
New / Modified:
- cache/db/entities/AuthSessionEntity.kt — adds `userJson: String?`.
- cache/db/AppDatabase.kt — version 1 → 2.
- cache/db/dao/AuthSessionDao.kt — adds setUserJson partial-update.
- auth/AuthStore.kt — `userJson: StateFlow<String?>` + setter +
persistUserJson. Refactored persist* methods to share a single
currentEntity() builder so adding the third field didn't triple
the boilerplate.
- models/UserRef.kt — @Serializable so AuthController can encode
it for storage.
- auth/AuthController.kt — injects ApplicationScope + Json. On init,
collects authStore.userJson and reflects decoded UserRef into
currentUser. signIn() now writes the user JSON through to
AuthStore; signOut() clears it. Decode failures collapse to null
rather than crash (worst case: blank username until next
sign-in).
- settings/ui/SettingsViewModel.kt — combine() over
authStore.baseUrl + authController.currentUser + a local
transient state flow, so the username updates the moment the
rehydrated UserRef arrives rather than being a one-shot snapshot
at VM construction time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
package com.fabledsword.minstrel.auth
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.UserRef
|
import com.fabledsword.minstrel.models.UserRef
|
||||||
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -16,18 +20,18 @@ import javax.inject.Singleton
|
|||||||
* `AuthController` from `auth_provider.dart`.
|
* `AuthController` from `auth_provider.dart`.
|
||||||
*
|
*
|
||||||
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
|
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
|
||||||
* Set-Cookie on the login response; this controller owns the
|
* Set-Cookie on the login response; the user identity itself
|
||||||
* in-memory user identity that the UI binds against.
|
* persists via AuthStore.userJson so the username + isAdmin survive
|
||||||
*
|
* cold restarts. On construction the controller subscribes to
|
||||||
* `currentUser` is null until a successful login. Across process
|
* AuthStore.userJson and reflects any persisted value into
|
||||||
* restarts the cookie survives in AuthStore but `currentUser`
|
* `currentUser` — that's what makes the Settings screen show the
|
||||||
* doesn't — a future refinement persists the user JSON alongside
|
* username after a fresh app launch without re-prompting.
|
||||||
* the cookie so the UI can render an authenticated shell on cold
|
|
||||||
* launch without re-prompting.
|
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class AuthController @Inject constructor(
|
class AuthController @Inject constructor(
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
|
private val json: Json,
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val api: AuthApi = retrofit.create()
|
private val api: AuthApi = retrofit.create()
|
||||||
@@ -40,6 +44,19 @@ class AuthController @Inject constructor(
|
|||||||
val isSignedIn: Boolean
|
val isSignedIn: Boolean
|
||||||
get() = !authStore.sessionCookie.value.isNullOrEmpty()
|
get() = !authStore.sessionCookie.value.isNullOrEmpty()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Rehydrate currentUser whenever the persisted userJson emits
|
||||||
|
// (cold start with a saved cookie OR explicit sign-in/out
|
||||||
|
// writing through AuthStore). Decode failures collapse to null
|
||||||
|
// so a malformed row can't crash the app — worst case is a
|
||||||
|
// blank username until the next sign-in writes a valid value.
|
||||||
|
scope.launch {
|
||||||
|
authStore.userJson.collect { raw ->
|
||||||
|
currentUserState.value = raw?.let { decodeUser(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit credentials. On success the AuthCookieInterceptor will
|
* Submit credentials. On success the AuthCookieInterceptor will
|
||||||
* have captured the Set-Cookie before this method returns; the
|
* have captured the Set-Cookie before this method returns; the
|
||||||
@@ -54,6 +71,7 @@ class AuthController @Inject constructor(
|
|||||||
isAdmin = response.user.isAdmin,
|
isAdmin = response.user.isAdmin,
|
||||||
)
|
)
|
||||||
currentUserState.value = user
|
currentUserState.value = user
|
||||||
|
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +84,10 @@ class AuthController @Inject constructor(
|
|||||||
suspend fun signOut() {
|
suspend fun signOut() {
|
||||||
currentUserState.value = null
|
currentUserState.value = null
|
||||||
authStore.setSessionCookie(null)
|
authStore.setSessionCookie(null)
|
||||||
|
authStore.setUserJson(null)
|
||||||
runCatching { api.logout() }
|
runCatching { api.logout() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun decodeUser(raw: String): UserRef? =
|
||||||
|
runCatching { json.decodeFromString(UserRef.serializer(), raw) }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,15 @@ class AuthStore @Inject constructor(
|
|||||||
private val baseUrlState = MutableStateFlow(DEFAULT_BASE_URL)
|
private val baseUrlState = MutableStateFlow(DEFAULT_BASE_URL)
|
||||||
val baseUrl: StateFlow<String> = baseUrlState.asStateFlow()
|
val baseUrl: StateFlow<String> = baseUrlState.asStateFlow()
|
||||||
|
|
||||||
|
private val userJsonState = MutableStateFlow<String?>(null)
|
||||||
|
val userJson: StateFlow<String?> = userJsonState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
dao.observe().collect { row ->
|
dao.observe().collect { row ->
|
||||||
sessionCookieState.value = row?.sessionCookie
|
sessionCookieState.value = row?.sessionCookie
|
||||||
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
||||||
|
userJsonState.value = row?.userJson
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,15 +60,14 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistBaseUrl(value) }
|
scope.launch { persistBaseUrl(value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setUserJson(value: String?) {
|
||||||
|
userJsonState.value = value
|
||||||
|
scope.launch { persistUserJson(value) }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun persistCookie(value: String?) {
|
private suspend fun persistCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(
|
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||||
AuthSessionEntity(
|
|
||||||
id = ROW_ID,
|
|
||||||
sessionCookie = value,
|
|
||||||
baseUrl = baseUrlState.value,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
dao.setSessionCookie(value)
|
dao.setSessionCookie(value)
|
||||||
}
|
}
|
||||||
@@ -72,18 +75,27 @@ class AuthStore @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun persistBaseUrl(value: String) {
|
private suspend fun persistBaseUrl(value: String) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(
|
dao.upsert(currentEntity().copy(baseUrl = value))
|
||||||
AuthSessionEntity(
|
|
||||||
id = ROW_ID,
|
|
||||||
sessionCookie = sessionCookieState.value,
|
|
||||||
baseUrl = value,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
dao.setBaseUrl(value)
|
dao.setBaseUrl(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun persistUserJson(value: String?) {
|
||||||
|
if (dao.get() == null) {
|
||||||
|
dao.upsert(currentEntity().copy(userJson = value))
|
||||||
|
} else {
|
||||||
|
dao.setUserJson(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||||
|
id = ROW_ID,
|
||||||
|
sessionCookie = sessionCookieState.value,
|
||||||
|
baseUrl = baseUrlState.value,
|
||||||
|
userJson = userJsonState.value,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
|
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
|
||||||
private const val ROW_ID = 0
|
private const val ROW_ID = 0
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
CachedHomeIndexEntity::class,
|
CachedHomeIndexEntity::class,
|
||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
|
|||||||
+4
@@ -26,4 +26,8 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the base URL. */
|
/** Partial update: change only the base URL. */
|
||||||
@Query("UPDATE auth_session SET baseUrl = :baseUrl WHERE id = 0")
|
@Query("UPDATE auth_session SET baseUrl = :baseUrl WHERE id = 0")
|
||||||
suspend fun setBaseUrl(baseUrl: String)
|
suspend fun setBaseUrl(baseUrl: String)
|
||||||
|
|
||||||
|
/** Partial update: change only the serialized user identity JSON. */
|
||||||
|
@Query("UPDATE auth_session SET userJson = :userJson WHERE id = 0")
|
||||||
|
suspend fun setUserJson(userJson: String?)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -4,19 +4,20 @@ import androidx.room.Entity
|
|||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-row table holding the user's session cookie + configured
|
* Single-row table holding the user's session cookie, configured
|
||||||
* server base URL. Replaces the in-memory `AuthStore` placeholder from
|
* server base URL, and the serialized current user identity.
|
||||||
* Phase 3.1; the public AuthStore API stays identical, only the
|
|
||||||
* storage swaps to Room-backed.
|
|
||||||
*
|
*
|
||||||
* `id = 0` is the only valid row. `sessionCookie` is null when the
|
* `id = 0` is the only valid row. `sessionCookie` is null when the
|
||||||
* user is logged out (or has never logged in). `baseUrl` always has
|
* user is logged out (or has never logged in). `baseUrl` always has
|
||||||
* a value — `AuthStore.DEFAULT_BASE_URL` is used until the user
|
* a value — `AuthStore.DEFAULT_BASE_URL` is used until the user
|
||||||
* configures one in Settings.
|
* configures one in Settings. `userJson` is the JSON-encoded
|
||||||
|
* UserRef captured at sign-in so the username + isAdmin survive
|
||||||
|
* a cold restart (the cookie alone doesn't carry identity).
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "auth_session")
|
@Entity(tableName = "auth_session")
|
||||||
data class AuthSessionEntity(
|
data class AuthSessionEntity(
|
||||||
@PrimaryKey val id: Int = 0,
|
@PrimaryKey val id: Int = 0,
|
||||||
val sessionCookie: String? = null,
|
val sessionCookie: String? = null,
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
|
val userJson: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package com.fabledsword.minstrel.models
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The caller's identity as returned by `POST /api/auth/login`. Narrow
|
* The caller's identity as returned by `POST /api/auth/login`. Narrow
|
||||||
* field set — no password hash, no api token, no Subsonic password —
|
* field set — no password hash, no api token, no Subsonic password —
|
||||||
* matches the server's `UserView` envelope.
|
* matches the server's `UserView` envelope.
|
||||||
|
*
|
||||||
|
* `@Serializable` so AuthStore can persist the current user across
|
||||||
|
* cold restarts via the JSON-encoded `userJson` column on
|
||||||
|
* `AuthSessionEntity`.
|
||||||
*/
|
*/
|
||||||
|
@Serializable
|
||||||
data class UserRef(
|
data class UserRef(
|
||||||
val id: String,
|
val id: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
|
|||||||
+36
-11
@@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
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.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -25,24 +26,48 @@ class SettingsViewModel @Inject constructor(
|
|||||||
authStore: AuthStore,
|
authStore: AuthStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(
|
private val transient = MutableStateFlow(TransientState())
|
||||||
SettingsState(
|
private val state_: MutableStateFlow<SettingsState> = MutableStateFlow(SettingsState())
|
||||||
serverUrl = authStore.baseUrl.value,
|
val state: StateFlow<SettingsState> = state_.asStateFlow()
|
||||||
username = authController.currentUser.value?.username.orEmpty(),
|
|
||||||
),
|
init {
|
||||||
)
|
// Compose the user-visible state from the auth observables
|
||||||
val state: StateFlow<SettingsState> = internal.asStateFlow()
|
// (so cross-restart rehydration shows the right username) plus
|
||||||
|
// the local sign-out spinner. Snapshotting currentUser at
|
||||||
|
// construction time would miss the rehydration that arrives
|
||||||
|
// microseconds after; collecting it keeps the card live.
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
authStore.baseUrl,
|
||||||
|
authController.currentUser,
|
||||||
|
transient,
|
||||||
|
) { url, user, t ->
|
||||||
|
SettingsState(
|
||||||
|
serverUrl = url,
|
||||||
|
username = user?.username.orEmpty(),
|
||||||
|
isSigningOut = t.isSigningOut,
|
||||||
|
signedOut = t.signedOut,
|
||||||
|
)
|
||||||
|
}.collect { state_.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun signOut() {
|
fun signOut() {
|
||||||
if (internal.value.isSigningOut) return
|
if (transient.value.isSigningOut) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
internal.update { it.copy(isSigningOut = true) }
|
transient.update { it.copy(isSigningOut = true) }
|
||||||
authController.signOut()
|
authController.signOut()
|
||||||
internal.update { it.copy(isSigningOut = false, signedOut = true) }
|
transient.update { it.copy(isSigningOut = false, signedOut = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun consumeSignedOut() {
|
fun consumeSignedOut() {
|
||||||
internal.update { it.copy(signedOut = false) }
|
transient.update { it.copy(signedOut = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Local-only screen state that isn't owned by AuthStore/Controller. */
|
||||||
|
private data class TransientState(
|
||||||
|
val isSigningOut: Boolean = false,
|
||||||
|
val signedOut: Boolean = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user