feat(android): port auth_session + promote AuthStore to Room (M8 4.2 slice 10)

Last slice. Promotes the Phase 3.1 in-memory AuthStore placeholder to a
Room-backed single-row auth_session table so session cookie + base URL
survive process death.

Design — hybrid storage:
  - MutableStateFlow is the primary read source so interceptor-thread
    reads stay synchronous (no awaiting a DAO call from inside an
    OkHttp interceptor)
  - Writes update the in-memory state synchronously AND launch a
    write-through coroutine that persists to the DAO
  - init() collects dao.observe() to keep in-memory in sync with
    persisted state on app start + any external DB writes

AuthSessionDao gets partial-update queries (`setSessionCookie` /
`setBaseUrl`) so we don't have to round-trip the full row on every
mutation. First write does an upsert to seed the row.

DatabaseModule grows a @Provides for AuthSessionDao — Hilt can't inject
AppDatabase's abstract DAO accessors directly; each consumer-needed DAO
gets a thin bridge.

AuthCookieInterceptorTest updated: AuthStore now takes (dao, scope)
constructor args. Test uses mockk for the DAO and TestScope with
UnconfinedTestDispatcher so the in-memory state mutations the test
asserts on aren't affected by the asynchronous DAO writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 17:21:42 -04:00
parent 954bb8963f
commit 156162e3ac
6 changed files with 150 additions and 8 deletions
@@ -1,34 +1,91 @@
package com.fabledsword.minstrel.auth
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Holds the user's session cookie and configured server base URL.
*
* **This is the in-memory placeholder.** Phase 4 (Task 4.2) replaces it
* with a Room-backed single-row table so state survives process death.
* The public surface stays the same — only the storage swap.
* **Hybrid storage**: a `MutableStateFlow` is the primary read source so
* interceptor-thread reads stay synchronous, and writes are persisted
* write-through to a Room `auth_session` single-row table for
* process-death persistence.
*
* Reads on init() rehydrate the in-memory state from the DAO, then a
* collection on `dao.observe()` keeps the in-memory state in sync with
* any external writes (e.g. multi-process pokes, future). On mutate the
* in-memory state changes synchronously so the next interceptor read
* sees the new value immediately; the DAO write coroutine catches up
* shortly after.
*/
@Singleton
class AuthStore @Inject constructor() {
class AuthStore @Inject constructor(
private val dao: AuthSessionDao,
@ApplicationScope private val scope: CoroutineScope,
) {
private val sessionCookieState = MutableStateFlow<String?>(null)
val sessionCookie: StateFlow<String?> get() = sessionCookieState
val sessionCookie: StateFlow<String?> = sessionCookieState.asStateFlow()
private val baseUrlState = MutableStateFlow(DEFAULT_BASE_URL)
val baseUrl: StateFlow<String> get() = baseUrlState
val baseUrl: StateFlow<String> = baseUrlState.asStateFlow()
init {
scope.launch {
dao.observe().collect { row ->
sessionCookieState.value = row?.sessionCookie
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
}
}
}
fun setSessionCookie(value: String?) {
sessionCookieState.value = value
scope.launch { persistCookie(value) }
}
fun setBaseUrl(value: String) {
baseUrlState.value = value
scope.launch { persistBaseUrl(value) }
}
private suspend fun persistCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(
AuthSessionEntity(
id = ROW_ID,
sessionCookie = value,
baseUrl = baseUrlState.value,
),
)
} else {
dao.setSessionCookie(value)
}
}
private suspend fun persistBaseUrl(value: String) {
if (dao.get() == null) {
dao.upsert(
AuthSessionEntity(
id = ROW_ID,
sessionCookie = sessionCookieState.value,
baseUrl = value,
),
)
} else {
dao.setBaseUrl(value)
}
}
companion object {
const val DEFAULT_BASE_URL = "http://localhost:8080"
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
private const val ROW_ID = 0
}
}
@@ -4,6 +4,7 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
@@ -16,6 +17,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
@@ -55,6 +57,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedMutationEntity::class,
CachedResumeStateEntity::class,
CachedHomeIndexEntity::class,
AuthSessionEntity::class,
],
version = 1,
exportSchema = true,
@@ -73,4 +76,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedMutationDao(): CachedMutationDao
abstract fun cachedResumeStateDao(): CachedResumeStateDao
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
abstract fun authSessionDao(): AuthSessionDao
}
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.cache.db
import android.content.Context
import androidx.room.Room
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -24,5 +25,11 @@ object DatabaseModule {
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
// DAO @Provides — Hilt can't inject AppDatabase abstract accessors
// directly. Each consumer-needed DAO gets a thin @Provides bridge.
@Provides
@Singleton
fun provideAuthSessionDao(db: AppDatabase): AuthSessionDao = db.authSessionDao()
private const val DATABASE_NAME = "minstrel.db"
}
@@ -0,0 +1,29 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AuthSessionDao {
@Query("SELECT * FROM auth_session WHERE id = 0")
fun observe(): Flow<AuthSessionEntity?>
@Query("SELECT * FROM auth_session WHERE id = 0")
suspend fun get(): AuthSessionEntity?
/** Upsert with both fields at once — used for first-run init. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: AuthSessionEntity)
/** Partial update: change only the session cookie. */
@Query("UPDATE auth_session SET sessionCookie = :cookie WHERE id = 0")
suspend fun setSessionCookie(cookie: String?)
/** Partial update: change only the base URL. */
@Query("UPDATE auth_session SET baseUrl = :baseUrl WHERE id = 0")
suspend fun setBaseUrl(baseUrl: String)
}
@@ -0,0 +1,22 @@
package com.fabledsword.minstrel.cache.db.entities
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.
*
* `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.
*/
@Entity(tableName = "auth_session")
data class AuthSessionEntity(
@PrimaryKey val id: Int = 0,
val sessionCookie: String? = null,
val baseUrl: String,
)
@@ -1,6 +1,13 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
@@ -19,7 +26,23 @@ class AuthCookieInterceptorTest {
@BeforeEach
fun setup() {
server = MockWebServer().apply { start() }
authStore = AuthStore()
// AuthStore is Room-backed in production (slice 10). The test only
// exercises the in-memory state mutations of set*; the DAO writes
// happen on the scope and don't affect the synchronous read path
// the interceptor uses. UnconfinedTestDispatcher executes the
// scope.launch blocks immediately so the mock DAO records the
// calls if we ever want to verify persistence.
val dao =
mockk<AuthSessionDao> {
every { observe() } returns flowOf(null)
coEvery { get() } returns null
coEvery { upsert(any()) } returns Unit
coEvery { setSessionCookie(any()) } returns Unit
coEvery { setBaseUrl(any()) } returns Unit
}
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
client =
OkHttpClient.Builder()
.addInterceptor(AuthCookieInterceptor(authStore))