feat(android): History tab cache-first via Room + Flow (SWR) — responsiveness

Goal is UI responsiveness: the History tab was fetch-on-visit and
showed a spinner on every open. Now it paints instantly from a cached
snapshot and refreshes underneath — the native mirror of Flutter's
cacheFirst _historyProvider (alwaysRefresh over the cached_history_
snapshot drift row).

Native implementation (idiomatic, not a drift transliteration):
* CachedHistorySnapshotEntity — single-row table (id=1) holding the
  raw /api/me/history wire JSON + updatedAt. AppDatabase v5→6
  (fallbackToDestructiveMigration rebuilds; cache refills from server).
* CachedHistorySnapshotDao.observe() Flow + upsert; DatabaseModule
  @Provides bridge (the AudioCacheIndexDao lesson — every @Inject dep
  needs a provider).
* HistoryRepository.observeHistory(): Flow<HistoryPage?> decodes the
  blob; refresh() fetches + overwrites the snapshot. Whole page as one
  JSON blob (read-only timestamp-keyed snapshot — no per-row bookkeeping
  for no UX gain), matching Flutter's snapshot shape.
* HistoryTabViewModel: uiState = combine(observeHistory, refreshError)
  — cache-first, SWR, and Error only when there's no cache to show.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 10:33:29 -04:00
parent 14330e99da
commit 4dc80047b2
6 changed files with 136 additions and 46 deletions
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
@@ -20,6 +21,7 @@ import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
@@ -57,9 +59,10 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedMutationEntity::class, CachedMutationEntity::class,
CachedResumeStateEntity::class, CachedResumeStateEntity::class,
CachedHomeIndexEntity::class, CachedHomeIndexEntity::class,
CachedHistorySnapshotEntity::class,
AuthSessionEntity::class, AuthSessionEntity::class,
], ],
version = 5, version = 6,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(MinstrelTypeConverters::class) @TypeConverters(MinstrelTypeConverters::class)
@@ -76,5 +79,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedMutationDao(): CachedMutationDao abstract fun cachedMutationDao(): CachedMutationDao
abstract fun cachedResumeStateDao(): CachedResumeStateDao abstract fun cachedResumeStateDao(): CachedResumeStateDao
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
abstract fun authSessionDao(): AuthSessionDao abstract fun authSessionDao(): AuthSessionDao
} }
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
@@ -65,6 +66,11 @@ object DatabaseModule {
fun provideCachedHomeIndexDao(db: AppDatabase): CachedHomeIndexDao = fun provideCachedHomeIndexDao(db: AppDatabase): CachedHomeIndexDao =
db.cachedHomeIndexDao() db.cachedHomeIndexDao()
@Provides
@Singleton
fun provideCachedHistorySnapshotDao(db: AppDatabase): CachedHistorySnapshotDao =
db.cachedHistorySnapshotDao()
@Provides @Provides
@Singleton @Singleton
fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao = fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao =
@@ -0,0 +1,18 @@
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.CachedHistorySnapshotEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedHistorySnapshotDao {
/** Observes the single snapshot row (null until the first fetch lands). */
@Query("SELECT * FROM cached_history_snapshot WHERE id = 1")
fun observe(): Flow<CachedHistorySnapshotEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: CachedHistorySnapshotEntity)
}
@@ -0,0 +1,26 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Single-row snapshot of the `/api/me/history` response, stored as the
* raw wire JSON so the History tab paints instantly from disk on cold
* open and survives connectivity loss. Mirrors the Flutter Drift
* `CachedHistorySnapshot` table (id defaults to 1; one row total).
*
* Cache-first + SWR: the tab observes this row and a background refresh
* overwrites it, so the freshest plays land without a blocking spinner.
*/
@Entity(tableName = "cached_history_snapshot")
data class CachedHistorySnapshotEntity(
@PrimaryKey val id: Int = SINGLETON_ID,
val json: String,
val updatedAt: Instant = Clock.System.now(),
) {
companion object {
const val SINGLETON_ID = 1
}
}
@@ -1,8 +1,14 @@
package com.fabledsword.minstrel.history.data package com.fabledsword.minstrel.history.data
import com.fabledsword.minstrel.api.endpoints.HistoryApi import com.fabledsword.minstrel.api.endpoints.HistoryApi
import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
import com.fabledsword.minstrel.library.data.toDomain import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.wire.HistoryPageWire
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.create import retrofit2.create
import javax.inject.Inject import javax.inject.Inject
@@ -21,36 +27,53 @@ data class HistoryEntry(
val track: TrackRef, val track: TrackRef,
) )
/**
* Read-through accessor for the caller's listening history.
*
* v1 is fetch-on-visit only — no Room caching. The Phase-13 offline
* pass adds a `cached_history_snapshot` JSON-blob mirror so the tab
* paints from disk on cold open and survives connectivity loss
* (matching `flutter_client/lib/library/library_screen.dart`'s
* SWR-style `historyProvider`).
*/
@Singleton
class HistoryRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: HistoryApi = retrofit.create()
suspend fun fetchPage(
limit: Int = HistoryApi.DEFAULT_LIMIT,
offset: Int = 0,
): HistoryPage {
val wire = api.getHistory(limit, offset)
return HistoryPage(
entries = wire.events.map {
HistoryEntry(id = it.id, playedAt = it.playedAt, track = it.track.toDomain())
},
hasMore = wire.hasMore,
)
}
}
data class HistoryPage( data class HistoryPage(
val entries: List<HistoryEntry>, val entries: List<HistoryEntry>,
val hasMore: Boolean, val hasMore: Boolean,
) )
/**
* Cache-first listening history. Mirrors Flutter's `_historyProvider`
* (`cacheFirst` over the `cached_history_snapshot` drift row with
* `alwaysRefresh`) using the native Room + Flow idiom:
*
* - [observeHistory] emits the cached snapshot instantly (null until
* the first fetch lands), so the History tab paints from disk on
* cold open with no blocking spinner.
* - [refresh] pulls `/api/me/history`, stores the raw wire JSON as
* the single snapshot row, and the Flow re-emits — SWR. Errors
* propagate to the caller (the VM surfaces them only when there's
* no cache to show).
*
* The whole page is stored as one JSON blob (single-row table) rather
* than per-row Room entities — history is a read-only timestamp-keyed
* snapshot, so a blob is the simplest faithful mirror of the Drift
* snapshot and avoids ordering/paging bookkeeping for no UX gain.
*/
@Singleton
class HistoryRepository @Inject constructor(
private val snapshotDao: CachedHistorySnapshotDao,
private val json: Json,
retrofit: Retrofit,
) {
private val api: HistoryApi = retrofit.create()
fun observeHistory(): Flow<HistoryPage?> =
snapshotDao.observe().map { row ->
row?.let { json.decodeFromString<HistoryPageWire>(it.json).toDomain() }
}
/** Fetch the latest history and overwrite the cached snapshot. */
suspend fun refresh() {
val wire = api.getHistory()
val blob = json.encodeToString(HistoryPageWire.serializer(), wire)
snapshotDao.upsert(CachedHistorySnapshotEntity(json = blob))
}
}
private fun HistoryPageWire.toDomain(): HistoryPage = HistoryPage(
entries = events.map {
HistoryEntry(id = it.id, playedAt = it.playedAt, track = it.track.toDomain())
},
hasMore = hasMore,
)
@@ -35,8 +35,10 @@ import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -48,6 +50,8 @@ import javax.inject.Inject
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface HistoryUiState { sealed interface HistoryUiState {
data object Loading : HistoryUiState data object Loading : HistoryUiState
data object Empty : HistoryUiState data object Empty : HistoryUiState
@@ -63,27 +67,36 @@ class HistoryTabViewModel @Inject constructor(
private val player: PlayerController, private val player: PlayerController,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<HistoryUiState>(HistoryUiState.Loading) private val refreshError = MutableStateFlow<String?>(null)
val uiState: StateFlow<HistoryUiState> = internal.asStateFlow()
/**
* Cache-first: emits the cached snapshot instantly (paints from
* disk on cold open), then SWR-refreshes underneath. Shows Error
* only when there's nothing cached AND the refresh failed — a
* failed refresh over a populated cache stays silent.
*/
val uiState: StateFlow<HistoryUiState> =
combine(repository.observeHistory(), refreshError) { page, err ->
when {
page != null && page.entries.isNotEmpty() -> HistoryUiState.Success(page.entries)
page != null -> HistoryUiState.Empty
err != null -> HistoryUiState.Error(err)
else -> HistoryUiState.Loading
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = HistoryUiState.Loading,
)
init { init {
refresh() refresh()
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
internal.value = HistoryUiState.Loading refreshError.value = null
try { runCatching { repository.refresh() }
val page = repository.fetchPage() .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
internal.value = if (page.entries.isEmpty()) {
HistoryUiState.Empty
} else {
HistoryUiState.Success(page.entries)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = HistoryUiState.Error(ErrorCopy.fromThrowable(e))
}
} }
/** Single-track play, matching the Flutter history-row behavior. */ /** Single-track play, matching the Flutter history-row behavior. */