library: stop download writes from invalidating the browse lists

The album/artist/playlist paging SQL named song_downloads, so Room
invalidated the pages on every download progress tick, janking the list
and resetting scroll when a download started. Route the default (filter
off) path through a raw query that only observes its own table; the
downloaded/offline path keeps the download-aware queries.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent adc206337b
commit 487296e75e
4 changed files with 374 additions and 90 deletions
@@ -4,6 +4,8 @@ import androidx.paging.PagingSource
import androidx.room3.Dao import androidx.room3.Dao
import androidx.room3.DaoReturnTypeConverters import androidx.room3.DaoReturnTypeConverters
import androidx.room3.Query import androidx.room3.Query
import androidx.room3.RawQuery
import androidx.room3.RoomRawQuery
import androidx.room3.Transaction import androidx.room3.Transaction
import androidx.room3.Upsert import androidx.room3.Upsert
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
@@ -432,6 +434,15 @@ interface LibraryDao {
) )
suspend fun deleteOrphanPlaylistSongs(sourceId: Long) suspend fun deleteOrphanPlaylistSongs(sourceId: Long)
@RawQuery(observedEntities = [Album::class])
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
@RawQuery(observedEntities = [Artist::class])
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
@RawQuery(observedEntities = [Playlist::class])
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
fun albumsByName( fun albumsByName(
sourceId: Long, sourceId: Long,
@@ -0,0 +1,142 @@
package com.subtracks.data.db
import androidx.room3.RoomRawQuery
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
// the resulting PagingSource is not invalidated by download writes (including progress updates).
internal const val ALBUMS_FILTER_BASE =
"FROM albums WHERE sourceId = ? " +
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
"AND (? = '' " +
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
"OR instr(lower(albumArtist), lower(?)) > 0))) "
internal const val ARTISTS_FILTER_BASE =
"FROM artists WHERE sourceId = ? " +
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
"AND (? = '' " +
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
internal const val PLAYLISTS_FILTER_BASE =
"FROM playlists WHERE sourceId = ? " +
"AND (? = '' " +
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
"WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
internal fun albumsDefaultQuery(
sourceId: Long,
starredFilter: Int,
search: String,
sort: AlbumSort,
descending: Boolean,
): RoomRawQuery {
val order = albumOrder(sort, descending)
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId)
stmt.bindInt(2, starredFilter)
stmt.bindInt(3, starredFilter)
stmt.bindInt(4, starredFilter)
stmt.bindText(5, search)
stmt.bindText(6, search)
stmt.bindText(7, search)
stmt.bindText(8, search)
stmt.bindText(9, search)
stmt.bindText(10, search)
}
}
internal fun artistsDefaultQuery(
sourceId: Long,
starredFilter: Int,
search: String,
sort: ArtistSort,
descending: Boolean,
): RoomRawQuery {
val order = artistOrder(sort, descending)
return RoomRawQuery("SELECT * $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId)
stmt.bindInt(2, starredFilter)
stmt.bindInt(3, starredFilter)
stmt.bindInt(4, starredFilter)
stmt.bindText(5, search)
stmt.bindText(6, search)
stmt.bindText(7, search)
stmt.bindText(8, search)
stmt.bindText(9, search)
}
}
internal fun playlistsDefaultQuery(
sourceId: Long,
search: String,
sort: PlaylistSort,
descending: Boolean,
): RoomRawQuery {
val order = playlistOrder(sort, descending)
return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId)
stmt.bindText(2, search)
stmt.bindText(3, search)
stmt.bindText(4, search)
stmt.bindText(5, search)
stmt.bindText(6, search)
}
}
private fun albumOrder(
sort: AlbumSort,
descending: Boolean,
): String =
when (sort) {
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
}
private fun artistOrder(
sort: ArtistSort,
descending: Boolean,
): String =
when (sort) {
ArtistSort.Name -> {
if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME
}
ArtistSort.AlbumCount -> {
if (descending) ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED else ARTIST_ORDER_BY_ALBUM_COUNT
}
ArtistSort.Starred -> {
if (descending) ARTIST_ORDER_BY_STARRED_REVERSED else ARTIST_ORDER_BY_STARRED
}
ArtistSort.Frequent -> {
if (descending) ARTIST_ORDER_BY_FREQUENT_REVERSED else ARTIST_ORDER_BY_FREQUENT
}
ArtistSort.Recent -> {
if (descending) ARTIST_ORDER_BY_RECENT_REVERSED else ARTIST_ORDER_BY_RECENT
}
}
private fun playlistOrder(
sort: PlaylistSort,
descending: Boolean,
): String =
when (sort) {
PlaylistSort.Name -> if (descending) PLAYLIST_ORDER_BY_NAME_REVERSED else PLAYLIST_ORDER_BY_NAME
PlaylistSort.Added -> if (descending) PLAYLIST_ORDER_BY_ADDED_REVERSED else PLAYLIST_ORDER_BY_ADDED
PlaylistSort.Updated -> if (descending) PLAYLIST_ORDER_BY_UPDATED_REVERSED else PLAYLIST_ORDER_BY_UPDATED
}
@@ -5,6 +5,9 @@ import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.PagingSource import androidx.paging.PagingSource
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.albumsDefaultQuery
import com.subtracks.data.db.artistsDefaultQuery
import com.subtracks.data.db.playlistsDefaultQuery
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
@@ -148,60 +151,64 @@ class LibraryRepository(
pager(40) { pager(40) {
val dao = db.libraryDao() val dao = db.libraryDao()
val downloadedFilter = if (downloaded) 1 else 0 val downloadedFilter = if (downloaded) 1 else 0
when (sort) { if (!downloaded) {
AlbumSort.Name -> { dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
if (descending) { } else {
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter) when (sort) {
} else { AlbumSort.Name -> {
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter) if (descending) {
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
AlbumSort.Artist -> { AlbumSort.Artist -> {
if (descending) { if (descending) {
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
AlbumSort.Year -> { AlbumSort.Year -> {
if (descending) { if (descending) {
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
AlbumSort.Added -> { AlbumSort.Added -> {
if (descending) { if (descending) {
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
AlbumSort.Starred -> { AlbumSort.Starred -> {
if (descending) { if (descending) {
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
AlbumSort.Frequent -> { AlbumSort.Frequent -> {
if (descending) { if (descending) {
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
AlbumSort.Recent -> { AlbumSort.Recent -> {
if (descending) { if (descending) {
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
} }
} }
@@ -218,44 +225,48 @@ class LibraryRepository(
pager(60) { pager(60) {
val dao = db.libraryDao() val dao = db.libraryDao()
val downloadedFilter = if (downloaded) 1 else 0 val downloadedFilter = if (downloaded) 1 else 0
when (sort) { if (!downloaded) {
ArtistSort.Name -> { dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
if (descending) { } else {
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter) when (sort) {
} else { ArtistSort.Name -> {
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter) if (descending) {
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
ArtistSort.AlbumCount -> { ArtistSort.AlbumCount -> {
if (descending) { if (descending) {
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
ArtistSort.Starred -> { ArtistSort.Starred -> {
if (descending) { if (descending) {
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
ArtistSort.Frequent -> { ArtistSort.Frequent -> {
if (descending) { if (descending) {
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
ArtistSort.Recent -> { ArtistSort.Recent -> {
if (descending) { if (descending) {
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
} }
} }
@@ -271,28 +282,32 @@ class LibraryRepository(
pager(40) { pager(40) {
val dao = db.libraryDao() val dao = db.libraryDao()
val downloadedFilter = if (downloaded) 1 else 0 val downloadedFilter = if (downloaded) 1 else 0
when (sort) { if (!downloaded) {
PlaylistSort.Name -> { dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
if (descending) { } else {
dao.playlistsByNameReversed(sourceId, search, downloadedFilter) when (sort) {
} else { PlaylistSort.Name -> {
dao.playlistsByName(sourceId, search, downloadedFilter) if (descending) {
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
} else {
dao.playlistsByName(sourceId, search, downloadedFilter)
}
} }
}
PlaylistSort.Added -> { PlaylistSort.Added -> {
if (descending) { if (descending) {
dao.playlistsByAddedReversed(sourceId, search, downloadedFilter) dao.playlistsByAddedReversed(sourceId, search, downloadedFilter)
} else { } else {
dao.playlistsByAdded(sourceId, search, downloadedFilter) dao.playlistsByAdded(sourceId, search, downloadedFilter)
}
} }
}
PlaylistSort.Updated -> { PlaylistSort.Updated -> {
if (descending) { if (descending) {
dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter) dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter)
} else { } else {
dao.playlistsByUpdated(sourceId, search, downloadedFilter) dao.playlistsByUpdated(sourceId, search, downloadedFilter)
}
} }
} }
} }
@@ -7,6 +7,7 @@ import androidx.room3.useReaderConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.awaitUntil
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
@@ -15,6 +16,9 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
@@ -24,6 +28,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.concurrent.atomic.AtomicBoolean
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LibraryDaoTest { class LibraryDaoTest {
@@ -703,6 +708,117 @@ class LibraryDaoTest {
} }
} }
@Test
fun defaultPagingQueriesMatchTheDownloadFreeFilters() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(sourceId, "a", "Beta", year = 2000, starred = null).copy(albumArtist = "Zed"),
album(sourceId, "b", "Alpha", year = 2010, starred = 5).copy(albumArtist = "Zed"),
album(sourceId, "c", "Gamma", year = 1990, starred = null).copy(albumArtist = "Ann"),
),
)
assertEquals(
listOf("Alpha", "Beta", "Gamma"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false)).page().map { it.name },
)
assertEquals(
listOf("Gamma", "Beta", "Alpha"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, true)).page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta", "Gamma"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Year, false)).page().map { it.name },
)
assertEquals(
listOf("Gamma", "Beta", "Alpha"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Artist, false)).page().map { it.name },
)
assertEquals(
listOf("Alpha"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "alp", AlbumSort.Name, false)).page().map { it.name },
)
assertEquals(
listOf("Gamma"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "am", AlbumSort.Name, false)).page().map { it.name },
)
assertEquals(
listOf("Alpha"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 1, "", AlbumSort.Name, false)).page().map { it.name },
)
assertEquals(
listOf("Beta", "Gamma"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 2, "", AlbumSort.Name, false)).page().map { it.name },
)
}
@Test
fun defaultArtistAndPlaylistPagingQueriesMatchTheDownloadFreeFilters() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "Beta", albumCount = 3),
artist(sourceId, "ar-2", "Alpha", albumCount = 1),
),
)
dao.upsertPlaylists(
listOf(
playlist(sourceId, "p1", "Beta", created = 0, changed = 5),
playlist(sourceId, "p2", "Alpha", created = 1, changed = 0),
),
)
assertEquals(
listOf("Alpha", "Beta"),
dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "a", ArtistSort.Name, false)).page().map { it.name },
)
assertEquals(
listOf("Beta", "Alpha"),
dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.AlbumCount, false)).page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta"),
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "a", PlaylistSort.Name, false)).page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta"),
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "", PlaylistSort.Added, false)).page().map { it.name },
)
assertEquals(
listOf("Beta", "Alpha"),
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "", PlaylistSort.Updated, false)).page().map { it.name },
)
}
@Test
fun downloadWritesDoNotInvalidateTheDefaultPagingSource() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(listOf(album(sourceId, "a", "Alpha", year = null, starred = null)))
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null).copy(albumId = "a")))
val defaultInvalidated = AtomicBoolean(false)
val default = dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false))
default.registerInvalidatedCallback { defaultInvalidated.set(true) }
default.page()
val downloadedInvalidated = AtomicBoolean(false)
val downloaded = dao.albumsByName(sourceId, 0, "", downloadedFilter = 1)
downloaded.registerInvalidatedCallback { downloadedInvalidated.set(true) }
downloaded.page()
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
awaitUntil("the downloaded list observes download writes") { downloadedInvalidated.get() }
assertFalse("the default list must not observe download writes", defaultInvalidated.get())
}
private suspend fun source(): Long = private suspend fun source(): Long =
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0)) db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))