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
import com.fabledsword.minstrel.api.endpoints.AuthApi
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.UserRef
import com.fabledsword.minstrel.models.wire.LoginRequestBody
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
@@ -16,18 +20,18 @@ import javax.inject.Singleton
* `AuthController` from `auth_provider.dart`.
*
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
* Set-Cookie on the login response; this controller owns the
* in-memory user identity that the UI binds against.
*
* `currentUser` is null until a successful login. Across process
* restarts the cookie survives in AuthStore but `currentUser`
* doesn't — a future refinement persists the user JSON alongside
* the cookie so the UI can render an authenticated shell on cold
* launch without re-prompting.
* Set-Cookie on the login response; the user identity itself
* persists via AuthStore.userJson so the username + isAdmin survive
* cold restarts. On construction the controller subscribes to
* AuthStore.userJson and reflects any persisted value into
* `currentUser` — that's what makes the Settings screen show the
* username after a fresh app launch without re-prompting.
*/
@Singleton
class AuthController @Inject constructor(
private val authStore: AuthStore,
private val json: Json,
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: AuthApi = retrofit.create()
@@ -40,6 +44,19 @@ class AuthController @Inject constructor(
val isSignedIn: Boolean
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
* have captured the Set-Cookie before this method returns; the
@@ -54,6 +71,7 @@ class AuthController @Inject constructor(
isAdmin = response.user.isAdmin,
)
currentUserState.value = user
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
return user
}
@@ -66,6 +84,10 @@ class AuthController @Inject constructor(
suspend fun signOut() {
currentUserState.value = null
authStore.setSessionCookie(null)
authStore.setUserJson(null)
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)
val baseUrl: StateFlow<String> = baseUrlState.asStateFlow()
private val userJsonState = MutableStateFlow<String?>(null)
val userJson: StateFlow<String?> = userJsonState.asStateFlow()
init {
scope.launch {
dao.observe().collect { row ->
sessionCookieState.value = row?.sessionCookie
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
userJsonState.value = row?.userJson
}
}
}
@@ -56,15 +60,14 @@ class AuthStore @Inject constructor(
scope.launch { persistBaseUrl(value) }
}
fun setUserJson(value: String?) {
userJsonState.value = value
scope.launch { persistUserJson(value) }
}
private suspend fun persistCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(
AuthSessionEntity(
id = ROW_ID,
sessionCookie = value,
baseUrl = baseUrlState.value,
),
)
dao.upsert(currentEntity().copy(sessionCookie = value))
} else {
dao.setSessionCookie(value)
}
@@ -72,18 +75,27 @@ class AuthStore @Inject constructor(
private suspend fun persistBaseUrl(value: String) {
if (dao.get() == null) {
dao.upsert(
AuthSessionEntity(
id = ROW_ID,
sessionCookie = sessionCookieState.value,
baseUrl = value,
),
)
dao.upsert(currentEntity().copy(baseUrl = value))
} else {
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 {
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
private const val ROW_ID = 0
@@ -59,7 +59,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHomeIndexEntity::class,
AuthSessionEntity::class,
],
version = 1,
version = 2,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -26,4 +26,8 @@ interface AuthSessionDao {
/** Partial update: change only the base URL. */
@Query("UPDATE auth_session SET baseUrl = :baseUrl WHERE id = 0")
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
/**
* Single-row table holding the user's session cookie + configured
* server base URL. Replaces the in-memory `AuthStore` placeholder from
* Phase 3.1; the public AuthStore API stays identical, only the
* storage swaps to Room-backed.
* Single-row table holding the user's session cookie, configured
* server base URL, and the serialized current user identity.
*
* `id = 0` is the only valid row. `sessionCookie` is null when the
* user is logged out (or has never logged in). `baseUrl` always has
* 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")
data class AuthSessionEntity(
@PrimaryKey val id: Int = 0,
val sessionCookie: String? = null,
val baseUrl: String,
val userJson: String? = null,
)
@@ -1,10 +1,17 @@
package com.fabledsword.minstrel.models
import kotlinx.serialization.Serializable
/**
* The caller's identity as returned by `POST /api/auth/login`. Narrow
* field set — no password hash, no api token, no Subsonic password —
* 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(
val id: String,
val username: String,
@@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -25,24 +26,48 @@ class SettingsViewModel @Inject constructor(
authStore: AuthStore,
) : ViewModel() {
private val internal = MutableStateFlow(
SettingsState(
serverUrl = authStore.baseUrl.value,
username = authController.currentUser.value?.username.orEmpty(),
),
)
val state: StateFlow<SettingsState> = internal.asStateFlow()
private val transient = MutableStateFlow(TransientState())
private val state_: MutableStateFlow<SettingsState> = MutableStateFlow(SettingsState())
val state: StateFlow<SettingsState> = state_.asStateFlow()
init {
// Compose the user-visible state from the auth observables
// (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() {
if (internal.value.isSigningOut) return
if (transient.value.isSigningOut) return
viewModelScope.launch {
internal.update { it.copy(isSigningOut = true) }
transient.update { it.copy(isSigningOut = true) }
authController.signOut()
internal.update { it.copy(isSigningOut = false, signedOut = true) }
transient.update { it.copy(isSigningOut = false, signedOut = true) }
}
}
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,
)
}