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:
2026-05-25 22:40:09 -04:00
parent 16b3a1e9e2
commit 5869ec9505
7 changed files with 110 additions and 39 deletions
@@ -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)
@@ -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?)
} }
@@ -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,
@@ -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,
)
} }