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

Closes #37. The Hidden tab was fetch-on-visit (spinner every open);
now it paints instantly from the cached_quarantine_mine Room table
and SWR-refreshes underneath. Mirrors Flutter's MyQuarantineController
(drift watch + transaction full-replace + optimistic flag/unflag).

The Room table + DAO already existed (CachedQuarantineEntity /
CachedQuarantineDao with observeAll + insert/delete) — they were just
never wired up. This commit connects them:
* DatabaseModule: provideCachedQuarantineDao (was missing from the
  graph — same gap as AudioCacheIndexDao).
* CachedQuarantineDao: atomic replaceAll(rows) (@Transaction
  clear+upsertAll, no flicker).
* QuarantineRepository: observeMine() Flow + refresh() full-replace;
  flag(track) / unflag(id) now mutate the cache optimistically (Flow
  re-emits instantly) then call the server, enqueueing on failure
  (intent persists, no rollback — matches Flutter). flag() takes a
  TrackRef to build the optimistic row; TrackActionsViewModel updated.
  listMine() kept for the TrackActions hidden-check.
* HiddenTabViewModel: uiState = combine(observeMine, refreshError),
  cache-first; SWR on init + on quarantine.* SSE; unflag via repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 11:14:28 -04:00
parent 4dc80047b2
commit ffc2acc010
5 changed files with 153 additions and 64 deletions
@@ -12,6 +12,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
@@ -71,6 +72,11 @@ object DatabaseModule {
fun provideCachedHistorySnapshotDao(db: AppDatabase): CachedHistorySnapshotDao = fun provideCachedHistorySnapshotDao(db: AppDatabase): CachedHistorySnapshotDao =
db.cachedHistorySnapshotDao() db.cachedHistorySnapshotDao()
@Provides
@Singleton
fun provideCachedQuarantineDao(db: AppDatabase): CachedQuarantineDao =
db.cachedQuarantineDao()
@Provides @Provides
@Singleton @Singleton
fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao = fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao =
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -33,4 +34,19 @@ interface CachedQuarantineDao {
@Query("DELETE FROM cached_quarantine_mine WHERE trackId IN (:trackIds)") @Query("DELETE FROM cached_quarantine_mine WHERE trackId IN (:trackIds)")
suspend fun deleteByTrackIds(trackIds: List<String>) suspend fun deleteByTrackIds(trackIds: List<String>)
@Query("DELETE FROM cached_quarantine_mine")
suspend fun clear()
/**
* Atomic full-replace from the server snapshot — one transaction so
* the `observeAll()` watcher sees a single merged emission, not a
* delete-then-insert flicker. Mirrors Flutter's MyQuarantineController
* `_refreshFromServer` transaction.
*/
@Transaction
suspend fun replaceAll(rows: List<CachedQuarantineEntity>) {
clear()
upsertAll(rows)
}
} }
@@ -2,14 +2,22 @@ package com.fabledsword.minstrel.quarantine.data
import com.fabledsword.minstrel.api.endpoints.FlagRequest import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.QuarantineApi import com.fabledsword.minstrel.api.endpoints.QuarantineApi
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.models.QuarantineRef import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.wire.QuarantineMineWire import com.fabledsword.minstrel.models.wire.QuarantineMineWire
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.create import retrofit2.create
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val MS_PER_SECOND = 1000
/** /**
* Outcome of an `unflag` call — mirrors LikesRepository.toggleLike's * Outcome of an `unflag` call — mirrors LikesRepository.toggleLike's
* RequestOutcome flavor. ACCEPTED = server confirmed; QUEUED = the * RequestOutcome flavor. ACCEPTED = server confirmed; QUEUED = the
@@ -25,54 +33,79 @@ enum class UnflagOutcome { ACCEPTED, QUEUED }
enum class FlagOutcome { ACCEPTED, QUEUED } enum class FlagOutcome { ACCEPTED, QUEUED }
/** /**
* Read-through accessor for the caller's quarantine list + offline- * Cache-first accessor for the caller's quarantine list + offline-
* first flag / unflag writes. No Room caching for v1 (same shape as * first flag / unflag writes. Mirrors Flutter's `MyQuarantineController`
* Requests/History — offline-snapshot mirror lands with Phase 13). * (drift `cached_quarantine_mine` + SWR + optimistic mutations) using
* the native Room + Flow idiom:
*
* - [observeMine] emits the cached rows, so the Hidden tab paints
* from disk instantly and survives connectivity loss.
* - [refresh] full-replaces the table from the server snapshot in a
* single transaction (no delete-then-insert flicker).
* - [flag] / [unflag] mutate the cache optimistically (the Flow
* re-emits immediately), call the server, and enqueue on failure
* so the intent persists offline. The optimistic row is NOT rolled
* back on failure — same as Flutter.
*/ */
@Singleton @Singleton
class QuarantineRepository @Inject constructor( class QuarantineRepository @Inject constructor(
private val dao: CachedQuarantineDao,
private val mutationQueue: MutationQueue, private val mutationQueue: MutationQueue,
retrofit: Retrofit, retrofit: Retrofit,
) { ) {
private val api: QuarantineApi = retrofit.create() private val api: QuarantineApi = retrofit.create()
/** Cache-first stream of the caller's hidden tracks (newest first). */
fun observeMine(): Flow<List<QuarantineRef>> =
dao.observeAll().map { rows -> rows.map { it.toDomain() } }
/** Pull the server snapshot and atomically replace the cached table. */
suspend fun refresh() {
val fresh = api.listMine().map { it.toEntity() }
dao.replaceAll(fresh)
}
/** Server snapshot without touching the cache — used by the TrackActions hidden check. */
suspend fun listMine(): List<QuarantineRef> = api.listMine().map { it.toDomain() } suspend fun listMine(): List<QuarantineRef> = api.listMine().map { it.toDomain() }
/** /**
* Flag a track for quarantine with [reason] (one of bad_rip, * Flag [track] for quarantine with [reason] (bad_rip / wrong_file /
* wrong_file, wrong_tags, duplicate, other) and an optional * wrong_tags / duplicate / other) and optional admin-visible
* [notes] string visible to admins. On transport failure the * [notes]. Optimistically inserts the cache row (the Hidden tab +
* call is enqueued via MutationQueue for later replay; server- * isHidden checks update instantly), then calls the server;
* side re-flagging the same (user, track) pair is idempotent. * transport failure enqueues for replay (server re-flag is
* idempotent).
*/ */
suspend fun flag(trackId: String, reason: String, notes: String): FlagOutcome = try { suspend fun flag(track: TrackRef, reason: String, notes: String): FlagOutcome {
api.flag(FlagRequest(trackId = trackId, reason = reason, notes = notes)) dao.upsert(track.toQuarantineEntity(reason, notes))
FlagOutcome.ACCEPTED return try {
} catch ( api.flag(FlagRequest(trackId = track.id, reason = reason, notes = notes))
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, FlagOutcome.ACCEPTED
) { } catch (
// Intentional swallow: queue the flag for later replay. @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
mutationQueue.enqueueQuarantineFlag(trackId, reason, notes) ) {
FlagOutcome.QUEUED mutationQueue.enqueueQuarantineFlag(track.id, reason, notes)
FlagOutcome.QUEUED
}
} }
suspend fun unflag(trackId: String): UnflagOutcome = try { suspend fun unflag(trackId: String): UnflagOutcome {
api.unflag(trackId) dao.deleteByTrackId(trackId)
UnflagOutcome.ACCEPTED return try {
} catch ( api.unflag(trackId)
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, UnflagOutcome.ACCEPTED
) { } catch (
// Intentional swallow: queue the unhide for later replay; the @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
// UI has already removed the row optimistically. Same rationale ) {
// as LikesRepository.toggleLike. mutationQueue.enqueueQuarantineUnflag(trackId)
mutationQueue.enqueueQuarantineUnflag(trackId) UnflagOutcome.QUEUED
UnflagOutcome.QUEUED }
} }
} }
// ── Mappers (internal — wire stays out of UI) ── // ── Mappers (internal — wire/entity stay out of UI) ──
private fun QuarantineMineWire.toDomain(): QuarantineRef = QuarantineRef( private fun QuarantineMineWire.toEntity(): CachedQuarantineEntity = CachedQuarantineEntity(
trackId = trackId, trackId = trackId,
reason = reason, reason = reason,
notes = notes, notes = notes,
@@ -85,3 +118,34 @@ private fun QuarantineMineWire.toDomain(): QuarantineRef = QuarantineRef(
artistId = artistId, artistId = artistId,
artistName = artistName, artistName = artistName,
) )
private fun CachedQuarantineEntity.toDomain(): QuarantineRef = QuarantineRef(
trackId = trackId,
reason = reason,
notes = notes,
createdAt = createdAt,
trackTitle = trackTitle,
trackDurationMs = trackDurationMs,
albumId = albumId,
albumTitle = albumTitle,
albumCoverArtPath = albumCoverArtPath,
artistId = artistId,
artistName = artistName,
)
private fun QuarantineMineWire.toDomain(): QuarantineRef = toEntity().toDomain()
private fun TrackRef.toQuarantineEntity(reason: String, notes: String): CachedQuarantineEntity =
CachedQuarantineEntity(
trackId = id,
reason = reason,
notes = notes.ifEmpty { null },
createdAt = Clock.System.now().toString(),
trackTitle = title,
trackDurationMs = durationSec * MS_PER_SECOND,
albumId = albumId,
albumTitle = albumTitle,
albumCoverArtPath = null,
artistId = artistId,
artistName = artistName,
)
@@ -9,12 +9,16 @@ import com.fabledsword.minstrel.quarantine.data.QuarantineRepository
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.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface HiddenTabUiState { sealed interface HiddenTabUiState {
data object Loading : HiddenTabUiState data object Loading : HiddenTabUiState
data object Empty : HiddenTabUiState data object Empty : HiddenTabUiState
@@ -28,17 +32,36 @@ class HiddenTabViewModel @Inject constructor(
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<HiddenTabUiState>(HiddenTabUiState.Loading) private val refreshError = MutableStateFlow<String?>(null)
val uiState: StateFlow<HiddenTabUiState> = internal.asStateFlow()
/**
* Cache-first: emits the cached `cached_quarantine_mine` rows
* instantly (paints from disk on cold open), SWR-refreshes
* underneath, and shows Error only when there's no cache AND the
* refresh failed. Optimistic flag/unflag write the cache, so the
* list updates without waiting on the server.
*/
val uiState: StateFlow<HiddenTabUiState> =
combine(repository.observeMine(), refreshError) { rows, err ->
when {
rows.isNotEmpty() -> HiddenTabUiState.Success(rows)
err != null -> HiddenTabUiState.Error(err)
else -> HiddenTabUiState.Empty
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = HiddenTabUiState.Loading,
)
init { init {
refresh() refresh()
// Live cross-device updates: any quarantine.* event (hide / // Live cross-device updates: any quarantine.* event (hide /
// unhide / resolve / delete from another device) re-fetches the // unhide / resolve / delete from another device) re-fetches the
// list while the tab is open. Mirrors Flutter's LiveEventsDispatcher // snapshot while the tab is open. Mirrors Flutter's
// invalidating myQuarantineProvider; here the screen-scoped VM // LiveEventsDispatcher invalidating myQuarantineProvider; here
// subscribes directly, per the Android dispatcher's documented // the screen-scoped VM subscribes directly, per the Android
// pattern for state with no shared repository-refresh. // dispatcher's documented pattern for screen-scoped state.
viewModelScope.launch { viewModelScope.launch {
eventsStream.events eventsStream.events
.filter { it.kind.startsWith("quarantine.") } .filter { it.kind.startsWith("quarantine.") }
@@ -47,37 +70,17 @@ class HiddenTabViewModel @Inject constructor(
} }
fun refresh(): Job = viewModelScope.launch { fun refresh(): Job = viewModelScope.launch {
internal.value = HiddenTabUiState.Loading refreshError.value = null
try { runCatching { repository.refresh() }
val rows = repository.listMine() .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
internal.value = if (rows.isEmpty()) {
HiddenTabUiState.Empty
} else {
HiddenTabUiState.Success(rows)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = HiddenTabUiState.Error(ErrorCopy.fromThrowable(e))
}
} }
/** /**
* Optimistic unhide: drop the row from local state immediately, * Optimistic unhide — the repository drops the cache row first (the
* call the server, refetch on success or rollback-via-refetch on * observeMine Flow re-emits so the list updates instantly), then
* failure. The repository handles the offline-queue fallback so * calls the server, enqueueing on failure so the intent persists.
* the user's intent persists across connectivity loss.
*/ */
fun unflag(trackId: String) { fun unflag(trackId: String) {
val before = internal.value
if (before is HiddenTabUiState.Success) {
val filtered = before.rows.filterNot { it.trackId == trackId }
internal.value = if (filtered.isEmpty()) {
HiddenTabUiState.Empty
} else {
HiddenTabUiState.Success(filtered)
}
}
viewModelScope.launch { viewModelScope.launch {
runCatching { repository.unflag(trackId) } runCatching { repository.unflag(trackId) }
} }
@@ -112,7 +112,7 @@ class TrackActionsViewModel @Inject constructor(
fun flag(track: TrackRef, reason: String, notes: String) { fun flag(track: TrackRef, reason: String, notes: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { quarantine.flag(track.id, reason, notes) } runCatching { quarantine.flag(track, reason, notes) }
.onSuccess { refreshHidden() } .onSuccess { refreshHidden() }
.onFailure { messages.tryEmit("Couldn't hide: ${ErrorCopy.fromThrowable(it)}") } .onFailure { messages.tryEmit("Couldn't hide: ${ErrorCopy.fromThrowable(it)}") }
} }