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
|
||||
|
||||
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)
|
||||
|
||||
+4
@@ -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?)
|
||||
}
|
||||
|
||||
+6
-5
@@ -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,
|
||||
|
||||
+36
-11
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user