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:
@@ -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"
|
||||
}
|
||||
|
||||
+29
@@ -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)
|
||||
}
|
||||
+22
@@ -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,
|
||||
)
|
||||
+24
-1
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user