feat(android): persist CacheSettings + PlayerModule reads from AuthStore

Foundation for audit #5 Storage settings. CacheSettings carries the
four user-tunable cache prefs (liked/rolling caps, prefetch window,
cache-liked toggle) mirroring Flutter's cache_settings_provider.dart
field-for-field. Defaults match Flutter (5 GiB per bucket, prefetch
window = 5, cache-liked = true).

Persistence rides AuthSessionEntity (the de-facto single-row prefs
table) as a JSON blob in a new cacheSettingsJson column. DB version
bump 4→5; destructive migration per the pre-release policy.

PlayerModule.provideCacheConfig now snapshots AuthStore.cacheSettings
at injection time. SimpleCache is constructed once per process, so
limit changes from the Settings UI (next commit) take effect on next
app launch. Documented in the @Provides KDoc.

Storage section UI lands in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 16:23:13 -04:00
parent 9baed7e579
commit b438772c96
6 changed files with 100 additions and 2 deletions
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.auth package com.fabledsword.minstrel.auth
import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
@@ -8,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -46,6 +48,11 @@ class AuthStore @Inject constructor(
private val clientIdState = MutableStateFlow<String?>(null) private val clientIdState = MutableStateFlow<String?>(null)
val clientId: StateFlow<String?> = clientIdState.asStateFlow() val clientId: StateFlow<String?> = clientIdState.asStateFlow()
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
private val json = Json { ignoreUnknownKeys = true }
init { init {
scope.launch { scope.launch {
dao.observe().collect { row -> dao.observe().collect { row ->
@@ -54,10 +61,18 @@ class AuthStore @Inject constructor(
userJsonState.value = row?.userJson userJsonState.value = row?.userJson
themeModeState.value = row?.themeMode themeModeState.value = row?.themeMode
clientIdState.value = row?.clientId clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
} }
} }
} }
private fun decodeCacheSettings(raw: String?): CacheSettings {
if (raw.isNullOrEmpty()) return CacheSettings.DEFAULT
return runCatching {
json.decodeFromString(CacheSettings.serializer(), raw)
}.getOrDefault(CacheSettings.DEFAULT)
}
fun setSessionCookie(value: String?) { fun setSessionCookie(value: String?) {
sessionCookieState.value = value sessionCookieState.value = value
scope.launch { persistCookie(value) } scope.launch { persistCookie(value) }
@@ -83,6 +98,12 @@ class AuthStore @Inject constructor(
scope.launch { persistClientId(value) } scope.launch { persistClientId(value) }
} }
fun setCacheSettings(value: CacheSettings) {
cacheSettingsState.value = value
val encoded = json.encodeToString(CacheSettings.serializer(), value)
scope.launch { persistCacheSettings(encoded) }
}
private suspend fun persistCookie(value: String?) { private suspend fun persistCookie(value: String?) {
if (dao.get() == null) { if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value)) dao.upsert(currentEntity().copy(sessionCookie = value))
@@ -123,6 +144,14 @@ class AuthStore @Inject constructor(
} }
} }
private suspend fun persistCacheSettings(json: String) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(cacheSettingsJson = json))
} else {
dao.setCacheSettingsJson(json)
}
}
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity( private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID, id = ROW_ID,
sessionCookie = sessionCookieState.value, sessionCookie = sessionCookieState.value,
@@ -130,6 +159,10 @@ class AuthStore @Inject constructor(
userJson = userJsonState.value, userJson = userJsonState.value,
themeMode = themeModeState.value, themeMode = themeModeState.value,
clientId = clientIdState.value, clientId = clientIdState.value,
cacheSettingsJson = json.encodeToString(
CacheSettings.serializer(),
cacheSettingsState.value,
),
) )
companion object { companion object {
@@ -0,0 +1,37 @@
package com.fabledsword.minstrel.cache.audiocache
import kotlinx.serialization.Serializable
private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
private const val DEFAULT_PREFETCH_WINDOW = 5
/**
* User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
* (cache_settings_provider.dart) field-for-field. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore].
*
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
* unlimited. Effective at next app start (SimpleCache is built once
* per process).
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
* Same 0=unlimited / restart-to-apply caveat.
* - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher
* pre-downloads. Has no effect until the prefetcher lands (separate
* audit follow-up) — persisted now so the user can pre-configure.
* - [cacheLikedTracks]: when true, liking a track should also pin its
* audio to the liked-cache bucket. Has no effect until pin-on-like
* lands (separate follow-up).
*
* Defaults match Flutter: 5 GiB per bucket, prefetch=5, cache-liked=true.
*/
@Serializable
data class CacheSettings(
val likedCapBytes: Long = FIVE_GIB_BYTES,
val rollingCapBytes: Long = FIVE_GIB_BYTES,
val prefetchWindow: Int = DEFAULT_PREFETCH_WINDOW,
val cacheLikedTracks: Boolean = true,
) {
companion object {
val DEFAULT: CacheSettings = CacheSettings()
}
}
@@ -59,7 +59,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHomeIndexEntity::class, CachedHomeIndexEntity::class,
AuthSessionEntity::class, AuthSessionEntity::class,
], ],
version = 4, version = 5,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(MinstrelTypeConverters::class) @TypeConverters(MinstrelTypeConverters::class)
@@ -38,4 +38,8 @@ interface AuthSessionDao {
/** Partial update: change only the stable client install id. */ /** Partial update: change only the stable client install id. */
@Query("UPDATE auth_session SET clientId = :clientId WHERE id = 0") @Query("UPDATE auth_session SET clientId = :clientId WHERE id = 0")
suspend fun setClientId(clientId: String?) suspend fun setClientId(clientId: String?)
/** Partial update: change only the serialized cache settings. */
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
suspend fun setCacheSettingsJson(json: String?)
} }
@@ -29,4 +29,11 @@ data class AuthSessionEntity(
val userJson: String? = null, val userJson: String? = null,
val themeMode: String? = null, val themeMode: String? = null,
val clientId: String? = null, val clientId: String? = null,
/**
* JSON-encoded CacheSettings (cache/audiocache/CacheSettings.kt).
* Null = use defaults. Stored as a JSON blob rather than four
* individual columns to keep schema changes cheap when the
* CacheSettings shape evolves.
*/
val cacheSettingsJson: String? = null,
) )
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -21,7 +22,23 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object PlayerModule { object PlayerModule {
/**
* CacheConfig snapshot from the user's persisted CacheSettings.
* SimpleCache is constructed once per process, so changes via the
* Settings UI take effect on next app launch (not mid-flight).
* AuthStore's StateFlow hydrates async from Room — when the
* factory @Provides runs, the value is either the persisted
* setting (if Room finished first) or CacheSettings.DEFAULT.
* Worst case the user's tuning lands on next-next launch, which
* is acceptable for a single-user device pref.
*/
@Provides @Provides
@Singleton @Singleton
fun provideCacheConfig(): CacheConfig = CacheConfig() fun provideCacheConfig(authStore: AuthStore): CacheConfig {
val s = authStore.cacheSettings.value
return CacheConfig(
likedCapBytes = s.likedCapBytes,
rollingCapBytes = s.rollingCapBytes,
)
}
} }