diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt index 9a5287ed..ece05791 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -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 = diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedQuarantineDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedQuarantineDao.kt index 9cc73e7f..302c97f9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedQuarantineDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedQuarantineDao.kt @@ -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) + + @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) { + clear() + upsertAll(rows) + } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt index 0af392c7..d8d4ac80 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/data/QuarantineRepository.kt @@ -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> = + 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 = 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, + ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt index 1573966a..eccc486e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/quarantine/ui/HiddenTabViewModel.kt @@ -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.Loading) - val uiState: StateFlow = internal.asStateFlow() + private val refreshError = MutableStateFlow(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 = + 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) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsViewModel.kt index 7e4748ea..feabb80e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/trackactions/TrackActionsViewModel.kt @@ -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)}") } }