From b438772c96987a32518f457bfd087b4c5eae4ebe Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 16:23:13 -0400 Subject: [PATCH] feat(android): persist CacheSettings + PlayerModule reads from AuthStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fabledsword/minstrel/auth/AuthStore.kt | 33 +++++++++++++++++ .../cache/audiocache/CacheSettings.kt | 37 +++++++++++++++++++ .../minstrel/cache/db/AppDatabase.kt | 2 +- .../minstrel/cache/db/dao/AuthSessionDao.kt | 4 ++ .../cache/db/entities/AuthSessionEntity.kt | 7 ++++ .../minstrel/player/PlayerModule.kt | 19 +++++++++- 6 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.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 5a7753b2..7319b662 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,5 +1,6 @@ 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.entities.AuthSessionEntity import com.fabledsword.minstrel.di.ApplicationScope @@ -8,6 +9,7 @@ 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 javax.inject.Inject import javax.inject.Singleton @@ -46,6 +48,11 @@ class AuthStore @Inject constructor( private val clientIdState = MutableStateFlow(null) val clientId: StateFlow = clientIdState.asStateFlow() + private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT) + val cacheSettings: StateFlow = cacheSettingsState.asStateFlow() + + private val json = Json { ignoreUnknownKeys = true } + init { scope.launch { dao.observe().collect { row -> @@ -54,10 +61,18 @@ class AuthStore @Inject constructor( userJsonState.value = row?.userJson themeModeState.value = row?.themeMode 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?) { sessionCookieState.value = value scope.launch { persistCookie(value) } @@ -83,6 +98,12 @@ class AuthStore @Inject constructor( 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?) { if (dao.get() == null) { 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( id = ROW_ID, sessionCookie = sessionCookieState.value, @@ -130,6 +159,10 @@ class AuthStore @Inject constructor( userJson = userJsonState.value, themeMode = themeModeState.value, clientId = clientIdState.value, + cacheSettingsJson = json.encodeToString( + CacheSettings.serializer(), + cacheSettingsState.value, + ), ) companion object { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt new file mode 100644 index 00000000..246ac2a0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt @@ -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() + } +} 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 0a473095..e84a8263 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 @@ -59,7 +59,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity CachedHomeIndexEntity::class, AuthSessionEntity::class, ], - version = 4, + version = 5, exportSchema = true, ) @TypeConverters(MinstrelTypeConverters::class) 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 index 6901490d..5bb2ccc9 100644 --- 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 @@ -38,4 +38,8 @@ interface AuthSessionDao { /** Partial update: change only the stable client install id. */ @Query("UPDATE auth_session SET clientId = :clientId WHERE id = 0") 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?) } 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 index 81528c6e..c36cfa34 100644 --- 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 @@ -29,4 +29,11 @@ data class AuthSessionEntity( val userJson: String? = null, val themeMode: 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, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt index cea2719c..ebba0584 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt @@ -1,5 +1,6 @@ package com.fabledsword.minstrel.player +import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.cache.audiocache.CacheConfig import dagger.Module import dagger.Provides @@ -21,7 +22,23 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) 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 @Singleton - fun provideCacheConfig(): CacheConfig = CacheConfig() + fun provideCacheConfig(authStore: AuthStore): CacheConfig { + val s = authStore.cacheSettings.value + return CacheConfig( + likedCapBytes = s.likedCapBytes, + rollingCapBytes = s.rollingCapBytes, + ) + } }