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.CachedPlaylistDao
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.CachedTrackDao
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
@@ -71,6 +72,11 @@ object DatabaseModule {
fun provideCachedHistorySnapshotDao(db: AppDatabase): CachedHistorySnapshotDao =
db.cachedHistorySnapshotDao()
@Provides
@Singleton
fun provideCachedQuarantineDao(db: AppDatabase): CachedQuarantineDao =
db.cachedQuarantineDao()
@Provides
@Singleton
fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao =
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
import kotlinx.coroutines.flow.Flow
@@ -33,4 +34,19 @@ interface CachedQuarantineDao {
@Query("DELETE FROM cached_quarantine_mine WHERE trackId IN (:trackIds)")
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.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.models.QuarantineRef
import com.fabledsword.minstrel.models.TrackRef
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.create
import javax.inject.Inject
import javax.inject.Singleton
private const val MS_PER_SECOND = 1000
/**
* Outcome of an `unflag` call — mirrors LikesRepository.toggleLike's
* RequestOutcome flavor. ACCEPTED = server confirmed; QUEUED = the
@@ -25,54 +33,79 @@ enum class UnflagOutcome { ACCEPTED, QUEUED }
enum class FlagOutcome { ACCEPTED, QUEUED }
/**
* Read-through accessor for the caller's quarantine list + offline-
* first flag / unflag writes. No Room caching for v1 (same shape as
* Requests/History — offline-snapshot mirror lands with Phase 13).
* Cache-first accessor for the caller's quarantine list + offline-
* first flag / unflag writes. Mirrors Flutter's `MyQuarantineController`
* (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
class QuarantineRepository @Inject constructor(
private val dao: CachedQuarantineDao,
private val mutationQueue: MutationQueue,
retrofit: Retrofit,
) {
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() }
/**
* Flag a track for quarantine with [reason] (one of bad_rip,
* wrong_file, wrong_tags, duplicate, other) and an optional
* [notes] string visible to admins. On transport failure the
* call is enqueued via MutationQueue for later replay; server-
* side re-flagging the same (user, track) pair is idempotent.
* Flag [track] for quarantine with [reason] (bad_rip / wrong_file /
* wrong_tags / duplicate / other) and optional admin-visible
* [notes]. Optimistically inserts the cache row (the Hidden tab +
* isHidden checks update instantly), then calls the server;
* transport failure enqueues for replay (server re-flag is
* idempotent).
*/
suspend fun flag(trackId: String, reason: String, notes: String): FlagOutcome = try {
api.flag(FlagRequest(trackId = trackId, reason = reason, notes = notes))
FlagOutcome.ACCEPTED
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: queue the flag for later replay.
mutationQueue.enqueueQuarantineFlag(trackId, reason, notes)
FlagOutcome.QUEUED
suspend fun flag(track: TrackRef, reason: String, notes: String): FlagOutcome {
dao.upsert(track.toQuarantineEntity(reason, notes))
return try {
api.flag(FlagRequest(trackId = track.id, reason = reason, notes = notes))
FlagOutcome.ACCEPTED
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
mutationQueue.enqueueQuarantineFlag(track.id, reason, notes)
FlagOutcome.QUEUED
}
}
suspend fun unflag(trackId: String): UnflagOutcome = try {
api.unflag(trackId)
UnflagOutcome.ACCEPTED
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: queue the unhide for later replay; the
// UI has already removed the row optimistically. Same rationale
// as LikesRepository.toggleLike.
mutationQueue.enqueueQuarantineUnflag(trackId)
UnflagOutcome.QUEUED
suspend fun unflag(trackId: String): UnflagOutcome {
dao.deleteByTrackId(trackId)
return try {
api.unflag(trackId)
UnflagOutcome.ACCEPTED
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
mutationQueue.enqueueQuarantineUnflag(trackId)
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,
reason = reason,
notes = notes,
@@ -85,3 +118,34 @@ private fun QuarantineMineWire.toDomain(): QuarantineRef = QuarantineRef(
artistId = artistId,
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 kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface HiddenTabUiState {
data object Loading : HiddenTabUiState
data object Empty : HiddenTabUiState
@@ -28,17 +32,36 @@ class HiddenTabViewModel @Inject constructor(
private val eventsStream: EventsStream,
) : ViewModel() {
private val internal = MutableStateFlow<HiddenTabUiState>(HiddenTabUiState.Loading)
val uiState: StateFlow<HiddenTabUiState> = internal.asStateFlow()
private val refreshError = MutableStateFlow<String?>(null)
/**
* 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 {
refresh()
// Live cross-device updates: any quarantine.* event (hide /
// unhide / resolve / delete from another device) re-fetches the
// list while the tab is open. Mirrors Flutter's LiveEventsDispatcher
// invalidating myQuarantineProvider; here the screen-scoped VM
// subscribes directly, per the Android dispatcher's documented
// pattern for state with no shared repository-refresh.
// snapshot while the tab is open. Mirrors Flutter's
// LiveEventsDispatcher invalidating myQuarantineProvider; here
// the screen-scoped VM subscribes directly, per the Android
// dispatcher's documented pattern for screen-scoped state.
viewModelScope.launch {
eventsStream.events
.filter { it.kind.startsWith("quarantine.") }
@@ -47,37 +70,17 @@ class HiddenTabViewModel @Inject constructor(
}
fun refresh(): Job = viewModelScope.launch {
internal.value = HiddenTabUiState.Loading
try {
val rows = repository.listMine()
internal.value = if (rows.isEmpty()) {
HiddenTabUiState.Empty
} else {
HiddenTabUiState.Success(rows)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = HiddenTabUiState.Error(ErrorCopy.fromThrowable(e))
}
refreshError.value = null
runCatching { repository.refresh() }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
}
/**
* Optimistic unhide: drop the row from local state immediately,
* call the server, refetch on success or rollback-via-refetch on
* failure. The repository handles the offline-queue fallback so
* the user's intent persists across connectivity loss.
* Optimistic unhide — the repository drops the cache row first (the
* observeMine Flow re-emits so the list updates instantly), then
* calls the server, enqueueing on failure so the intent persists.
*/
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 {
runCatching { repository.unflag(trackId) }
}
@@ -112,7 +112,7 @@ class TrackActionsViewModel @Inject constructor(
fun flag(track: TrackRef, reason: String, notes: String) {
viewModelScope.launch {
runCatching { quarantine.flag(track.id, reason, notes) }
runCatching { quarantine.flag(track, reason, notes) }
.onSuccess { refreshHidden() }
.onFailure { messages.tryEmit("Couldn't hide: ${ErrorCopy.fromThrowable(it)}") }
}