From 156162e3ac3f0bb3a038e292b4c083c6725aea84 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 22 May 2026 17:21:42 -0400 Subject: [PATCH] feat(android): port auth_session + promote AuthStore to Room (M8 4.2 slice 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fabledsword/minstrel/auth/AuthStore.kt | 71 +++++++++++++++++-- .../minstrel/cache/db/AppDatabase.kt | 4 ++ .../minstrel/cache/db/DatabaseModule.kt | 7 ++ .../minstrel/cache/db/dao/AuthSessionDao.kt | 29 ++++++++ .../cache/db/entities/AuthSessionEntity.kt | 22 ++++++ .../minstrel/api/AuthCookieInterceptorTest.kt | 25 ++++++- 6 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt 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 fd5701d6..a125e6ba 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 @@ -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(null) - val sessionCookie: StateFlow get() = sessionCookieState + val sessionCookie: StateFlow = sessionCookieState.asStateFlow() private val baseUrlState = MutableStateFlow(DEFAULT_BASE_URL) - val baseUrl: StateFlow get() = baseUrlState + val baseUrl: StateFlow = 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 } } 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 a55f590f..99186857 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 @@ -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 } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt index c3a66e0a..15b35315 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -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" } 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 new file mode 100644 index 00000000..8150ab8b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt @@ -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 + + @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) +} 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 new file mode 100644 index 00000000..6fa1758a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt @@ -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, +) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt index 99592c35..e9c39baa 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt @@ -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 { + 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))