From 5869ec95052616d923ccf9635524e18e456b2c9d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 22:40:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2017b=20=E2=80=94=20pers?= =?UTF-8?q?ist=20user=20identity=20across=20cold=20restart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` + 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) --- .../minstrel/auth/AuthController.kt | 38 +++++++++++---- .../fabledsword/minstrel/auth/AuthStore.kt | 40 ++++++++++------ .../minstrel/cache/db/AppDatabase.kt | 2 +- .../minstrel/cache/db/dao/AuthSessionDao.kt | 4 ++ .../cache/db/entities/AuthSessionEntity.kt | 11 +++-- .../fabledsword/minstrel/models/UserRef.kt | 7 +++ .../minstrel/settings/ui/SettingsViewModel.kt | 47 ++++++++++++++----- 7 files changed, 110 insertions(+), 39 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt index 05150cf1..419a815c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt @@ -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() } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt index a125e6ba..2345f7be 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt @@ -37,11 +37,15 @@ class AuthStore @Inject constructor( private val baseUrlState = MutableStateFlow(DEFAULT_BASE_URL) val baseUrl: StateFlow = baseUrlState.asStateFlow() + private val userJsonState = MutableStateFlow(null) + val userJson: StateFlow = 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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt index 99186857..78090c67 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt index 8150ab8b..bb2e20bd 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt @@ -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?) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt index 6fa1758a..fe24afa6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt @@ -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, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt index 404489d3..35572695 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt @@ -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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt index 27d19853..badfddb9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsViewModel.kt @@ -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 = internal.asStateFlow() + private val transient = MutableStateFlow(TransientState()) + private val state_: MutableStateFlow = MutableStateFlow(SettingsState()) + val state: StateFlow = 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, + ) }