fix slider + list bugs

This commit is contained in:
Boof2015
2026-07-25 15:46:38 -04:00
parent 0f34ca2e8d
commit a33a3b7137
15 changed files with 960 additions and 209 deletions
@@ -381,6 +381,95 @@ class RoomLibraryRepositoryTest {
assertTrue(first.map { it.path }.intersect(second.map { it.path }.toSet()).isEmpty())
}
/**
* Walking backwards has to reproduce the forward ordering exactly — an off-by-one in
* the reversed tie-break tuple would either duplicate a row or silently skip one when
* the list refills upwards after an A-Z jump.
*/
@Test
fun backwardTitlePagesMirrorForwardPages() = runBlocking {
publish("g1", seedAlphabet())
val dao = catalog.catalogDao()
val forward = mutableListOf<ActiveTrackView>()
var afterKey: String? = null
var afterPath = ""
while (true) {
val page = dao.getTitlePage(afterKey, afterPath, 40)
if (page.isEmpty()) break
forward += page
afterKey = page.last().titleSortKey
afterPath = page.last().path
}
assertEquals(ALPHABET_SEED_SIZE, forward.size)
val backward = mutableListOf<ActiveTrackView>()
var beforeKey = forward.last().titleSortKey
var beforePath = forward.last().path
while (true) {
val page = dao.getTitlePageBefore(beforeKey, beforePath, 40)
if (page.isEmpty()) break
backward += page
beforeKey = page.last().titleSortKey
beforePath = page.last().path
}
// Backward pages are exclusive of the anchor row and come back descending.
assertEquals(
forward.dropLast(1).map { it.path }.reversed(),
backward.map { it.path },
)
// Nothing above the very first row.
assertTrue(dao.getTitlePageBefore(forward.first().titleSortKey, forward.first().path, 40).isEmpty())
}
/**
* The exact shape a rail jump uses: a section anchor carries only the leading sort key
* with an empty path tie-break, so the forward page starts at the letter and the
* backward page must be the slice immediately above it — no overlap, no gap.
*/
@Test
fun sectionAnchorSplitsCatalogWithoutOverlapOrGap() = runBlocking {
publish("g1", seedAlphabet())
val dao = catalog.catalogDao()
val all = mutableListOf<ActiveTrackView>()
var afterKey: String? = null
var afterPath = ""
while (true) {
val page = dao.getTitlePage(afterKey, afterPath, 100)
if (page.isEmpty()) break
all += page
afterKey = page.last().titleSortKey
afterPath = page.last().path
}
val anchor = dao.getTitleSectionAnchors().first { it.sectionLabel == "F" }
val at = dao.getTitlePage(anchor.sortKey, "", 40)
val above = dao.getTitlePageBefore(anchor.sortKey, "", 40)
assertEquals("F", SortKeys.sectionLabel(at.first().title))
assertTrue(above.all { SortKeys.sectionLabel(it.title) != "F" })
val anchorIndex = all.indexOfFirst { it.path == at.first().path }
assertEquals(40, above.size)
assertEquals(
all.subList(anchorIndex - above.size, anchorIndex).map { it.path },
above.reversed().map { it.path },
)
}
/** 10 tracks under each of A-Z, so every section has rows above and below it. */
private fun seedAlphabet(): List<TrackEntity> =
(0 until ALPHABET_SEED_SIZE).map { index ->
val letter = 'A' + (index / 10)
track(
generation = "g1",
index = index,
title = "$letter${"%03d".format(index)} Song",
path = "content://track/$index.flac",
)
}
private suspend fun publish(
generation: String,
tracks: List<TrackEntity>,
@@ -442,4 +531,9 @@ class RoomLibraryRepositoryTest {
trackSort = index,
sectionLabel = SortKeys.sectionLabel(title),
)
private companion object {
/** 26 letters x 10 tracks. */
const val ALPHABET_SEED_SIZE = 260
}
}
@@ -90,6 +90,18 @@ class AstraLibraryDataModule : Module() {
}
}
AsyncFunction("getTrackPageBefore") Coroutine {
sort: String,
cursor: String?,
limit: Int,
->
try {
repository().getTrackPageBefore(sort, cursor, limit)
} catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION")
}
}
AsyncFunction("getTrack") Coroutine { path: String ->
repository().getTrack(path)
}
@@ -367,6 +379,19 @@ class AstraLibraryDataModule : Module() {
}
}
AsyncFunction("getAlbumPageBefore") Coroutine {
sort: String,
includeSingles: Boolean,
cursor: String?,
limit: Int,
->
try {
repository().getAlbumPageBefore(sort, includeSingles, cursor, limit)
} catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION")
}
}
AsyncFunction("getArtistPage") Coroutine {
sort: String,
groupingMode: String,
@@ -381,6 +406,20 @@ class AstraLibraryDataModule : Module() {
}
}
AsyncFunction("getArtistPageBefore") Coroutine {
sort: String,
groupingMode: String,
includeCollaborations: Boolean,
cursor: String?,
limit: Int,
->
try {
repository().getArtistPageBefore(sort, groupingMode, includeCollaborations, cursor, limit)
} catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION")
}
}
AsyncFunction("getAlbumDetail") Coroutine { albumKey: String, cursor: String?, limit: Int ->
try {
repository().getAlbumDetail(albumKey, cursor, limit)
@@ -596,39 +596,7 @@ class AstraLibraryRepository private constructor(
limit = limit,
)
}
val next = rows.lastOrNull()?.let { row ->
when (sort) {
"artist" -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
text1 = row.artistSortKey,
text2 = row.albumSortKey,
text3 = "${row.titleSortKey}\u0000${row.path}",
number1 = row.discSort.toLong(),
number2 = row.trackSort.toLong(),
// Artist cursor needs one extra string. Encode the path alongside the
// title key with a NUL delimiter; SAF paths cannot contain NUL.
)
"recently_added" -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
text1 = row.path,
number1 = row.addedAt,
)
"duration" -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
text1 = row.path,
decimal1 = row.duration,
)
else -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
text1 = row.titleSortKey,
text2 = row.path,
)
}.encode()
}
val next = rows.lastOrNull()?.let { row -> trackCursor(revision, sort, row).encode() }
mapOf(
"items" to rows.map(ActiveTrackView::toBridgeMap),
"nextCursor" to next,
@@ -638,6 +606,93 @@ class AstraLibraryRepository private constructor(
)
}
/**
* The page immediately *above* [cursorRaw] — the backward twin of [getTrackPage].
* An A-Z rail jump drops the list into the middle of the catalog; this is what the
* rows above it are fetched with when the user scrolls back up. Items come back
* ascending, like every other page.
*
* `previousCursor` is non-null only on a full page: a short one means we walked off
* the head of the catalog, so the caller knows to stop asking.
*
* Only the sorts the rail is offered for (`title`, `artist`) can be walked backwards;
* anything else returns an empty page.
*/
suspend fun getTrackPageBefore(
sort: String,
cursorRaw: String?,
requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database ->
initialize()
val dao = database.catalogDao()
val revision = dao.getRevision()
val cursor = validateCursor(cursorRaw, revision, "tracks:$sort")
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val descending = when {
cursor == null -> emptyList()
sort == "artist" -> dao.getArtistOrderPageBefore(
beforeArtistKey = cursor.text1.orEmpty(),
beforeAlbumKey = cursor.text2.orEmpty(),
beforeDisc = cursor.number1?.toInt() ?: 0,
beforeTrack = cursor.number2?.toInt() ?: 0,
beforeTitleKey = cursorTitleKey(cursor),
beforePath = cursorPath(cursor),
limit = limit,
)
sort == "title" -> dao.getTitlePageBefore(
beforeTitleKey = cursor.text1.orEmpty(),
beforePath = cursor.text2.orEmpty(),
limit = limit,
)
else -> emptyList()
}
// The DESC result's last row is the topmost one — the cursor for the page above this one.
val previous = descending.takeIf { it.size == limit }
?.lastOrNull()
?.let { row -> trackCursor(revision, sort, row).encode() }
mapOf(
"items" to descending.reversed().map(ActiveTrackView::toBridgeMap),
"nextCursor" to null,
"previousCursor" to previous,
"totalCount" to dao.countActiveTracks().toDouble(),
"catalogRevision" to revision.toString(),
)
}
/** Shared by the forward and backward track pages so the two can never disagree. */
private fun trackCursor(revision: Long, sort: String, row: ActiveTrackView): TrackPageCursor =
when (sort) {
"artist" -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
text1 = row.artistSortKey,
text2 = row.albumSortKey,
text3 = "${row.titleSortKey}\u0000${row.path}",
number1 = row.discSort.toLong(),
number2 = row.trackSort.toLong(),
// Artist cursor needs one extra string. Encode the path alongside the
// title key with a NUL delimiter; SAF paths cannot contain NUL.
)
"recently_added" -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
text1 = row.path,
number1 = row.addedAt,
)
"duration" -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
text1 = row.path,
decimal1 = row.duration,
)
else -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
text1 = row.titleSortKey,
text2 = row.path,
)
}
suspend fun getTrack(path: String): Map<String, Any?>? =
withCatalogRecovery { database -> database.catalogDao().getActiveTrack(path)?.toBridgeMap() }
@@ -1674,36 +1729,7 @@ class AstraLibraryRepository private constructor(
limit,
)
}
val next = rows.lastOrNull()?.let { row ->
when (sort) {
"artist" -> TrackPageCursor(
revision,
kind,
text1 = row.artistSortKey,
text2 = row.nameSortKey,
text3 = row.identityKey,
)
"recently_added" -> TrackPageCursor(
revision,
kind,
text1 = row.identityKey,
number1 = row.latestAddedAt,
)
"year" -> TrackPageCursor(
revision,
kind,
text1 = row.nameSortKey,
text2 = row.identityKey,
number1 = (row.year ?: 0).toLong(),
)
else -> TrackPageCursor(
revision,
kind,
text1 = row.nameSortKey,
text2 = row.identityKey,
)
}.encode()
}
val next = rows.lastOrNull()?.let { row -> albumCursor(revision, kind, sort, row).encode() }
mapOf(
"items" to rows.map(AlbumSummaryEntity::toBridgeMap),
"nextCursor" to next,
@@ -1713,6 +1739,85 @@ class AstraLibraryRepository private constructor(
)
}
/** Backward twin of [getAlbumPage]; see [getTrackPageBefore] for the contract. */
suspend fun getAlbumPageBefore(
sort: String,
includeSingles: Boolean,
cursorRaw: String?,
requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database ->
val dao = database.catalogDao()
val revision = dao.getRevision()
val kind = "albums:$sort:${if (includeSingles) 1 else 0}"
val cursor = validateCursor(cursorRaw, revision, kind)
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val descending = when {
cursor == null -> emptyList()
sort == "artist" -> dao.getAlbumArtistPageBefore(
revision,
includeSingles,
cursor.text1.orEmpty(),
cursor.text2.orEmpty(),
cursor.text3.orEmpty(),
limit,
)
sort == "name" -> dao.getAlbumNamePageBefore(
revision,
includeSingles,
cursor.text1.orEmpty(),
cursor.text2.orEmpty(),
limit,
)
else -> emptyList()
}
val previous = descending.takeIf { it.size == limit }
?.lastOrNull()
?.let { row -> albumCursor(revision, kind, sort, row).encode() }
mapOf(
"items" to descending.reversed().map(AlbumSummaryEntity::toBridgeMap),
"nextCursor" to null,
"previousCursor" to previous,
"totalCount" to dao.countAlbums(revision, includeSingles).toDouble(),
"catalogRevision" to revision.toString(),
)
}
/** Shared by the forward and backward album pages so the two can never disagree. */
private fun albumCursor(
revision: Long,
kind: String,
sort: String,
row: AlbumSummaryEntity,
): TrackPageCursor =
when (sort) {
"artist" -> TrackPageCursor(
revision,
kind,
text1 = row.artistSortKey,
text2 = row.nameSortKey,
text3 = row.identityKey,
)
"recently_added" -> TrackPageCursor(
revision,
kind,
text1 = row.identityKey,
number1 = row.latestAddedAt,
)
"year" -> TrackPageCursor(
revision,
kind,
text1 = row.nameSortKey,
text2 = row.identityKey,
number1 = (row.year ?: 0).toLong(),
)
else -> TrackPageCursor(
revision,
kind,
text1 = row.nameSortKey,
text2 = row.identityKey,
)
}
suspend fun getArtistPage(
sort: String,
groupingMode: String,
@@ -1746,15 +1851,7 @@ class AstraLibraryRepository private constructor(
limit,
)
}
val next = rows.lastOrNull()?.let { row ->
TrackPageCursor(
revision,
kind,
text1 = row.nameSortKey,
text2 = row.artistKey,
number1 = if (sort == "track_count") row.trackCount else null,
).encode()
}
val next = rows.lastOrNull()?.let { row -> artistCursor(revision, kind, sort, row).encode() }
mapOf(
"items" to rows.map(ArtistSummaryEntity::toBridgeMap),
"nextCursor" to next,
@@ -1764,6 +1861,59 @@ class AstraLibraryRepository private constructor(
)
}
/** Backward twin of [getArtistPage]; see [getTrackPageBefore] for the contract. */
suspend fun getArtistPageBefore(
sort: String,
groupingMode: String,
includeCollaborations: Boolean,
cursorRaw: String?,
requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database ->
val dao = database.catalogDao()
val revision = dao.getRevision()
val mode = if (groupingMode == "fileTags") "fileTags" else "astra"
val kind = "artists:$sort:$mode:${if (includeCollaborations) 1 else 0}"
val cursor = validateCursor(cursorRaw, revision, kind)
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val descending = if (cursor == null || sort == "track_count") {
emptyList()
} else {
dao.getArtistNamePageBefore(
revision,
mode,
includeCollaborations,
cursor.text1.orEmpty(),
cursor.text2.orEmpty(),
limit,
)
}
val previous = descending.takeIf { it.size == limit }
?.lastOrNull()
?.let { row -> artistCursor(revision, kind, sort, row).encode() }
mapOf(
"items" to descending.reversed().map(ArtistSummaryEntity::toBridgeMap),
"nextCursor" to null,
"previousCursor" to previous,
"totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(),
"catalogRevision" to revision.toString(),
)
}
/** Shared by the forward and backward artist pages so the two can never disagree. */
private fun artistCursor(
revision: Long,
kind: String,
sort: String,
row: ArtistSummaryEntity,
): TrackPageCursor =
TrackPageCursor(
revision,
kind,
text1 = row.nameSortKey,
text2 = row.artistKey,
number1 = if (sort == "track_count") row.trackCount else null,
)
suspend fun getAlbumDetail(
albumKey: String,
cursorRaw: String?,
@@ -360,6 +360,26 @@ interface CatalogDao {
limit: Int,
): List<ActiveTrackView>
/**
* Mirror of [getTitlePage] walking backwards. Rows come out DESC — the caller
* reverses them so `items` is ascending like every other page. Backward paging
* always has a cursor (there is nothing before the head), so no NULL branch.
*/
@Query(
"""
SELECT * FROM active_tracks
WHERE title_sort_key < :beforeTitleKey
OR (title_sort_key = :beforeTitleKey AND path < :beforePath)
ORDER BY title_sort_key DESC, path DESC
LIMIT :limit
""",
)
suspend fun getTitlePageBefore(
beforeTitleKey: String,
beforePath: String,
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
@@ -387,6 +407,34 @@ interface CatalogDao {
limit: Int,
): List<ActiveTrackView>
/** Mirror of [getArtistOrderPage] walking backwards; rows come out DESC. */
@Query(
"""
SELECT * FROM active_tracks
WHERE artist_sort_key < :beforeArtistKey
OR (artist_sort_key = :beforeArtistKey AND album_sort_key < :beforeAlbumKey)
OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort < :beforeDisc)
OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort = :beforeDisc
AND track_sort < :beforeTrack)
OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort = :beforeDisc
AND track_sort = :beforeTrack AND title_sort_key < :beforeTitleKey)
OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort = :beforeDisc
AND track_sort = :beforeTrack AND title_sort_key = :beforeTitleKey AND path < :beforePath)
ORDER BY artist_sort_key DESC, album_sort_key DESC, disc_sort DESC, track_sort DESC,
title_sort_key DESC, path DESC
LIMIT :limit
""",
)
suspend fun getArtistOrderPageBefore(
beforeArtistKey: String,
beforeAlbumKey: String,
beforeDisc: Int,
beforeTrack: Int,
beforeTitleKey: String,
beforePath: String,
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
@@ -543,6 +591,26 @@ interface CatalogDao {
limit: Int,
): List<AlbumSummaryEntity>
/** Mirror of [getAlbumNamePage] walking backwards; rows come out DESC. */
@Query(
"""
SELECT * FROM album_summaries
WHERE revision = :revision
AND (:includeSingles OR is_single = 0)
AND (name_sort_key < :beforeKey
OR (name_sort_key = :beforeKey AND identity_key < :beforeId))
ORDER BY name_sort_key DESC, identity_key DESC
LIMIT :limit
""",
)
suspend fun getAlbumNamePageBefore(
revision: Long,
includeSingles: Boolean,
beforeKey: String,
beforeId: String,
limit: Int,
): List<AlbumSummaryEntity>
@Query(
"""
SELECT * FROM album_summaries
@@ -566,6 +634,29 @@ interface CatalogDao {
limit: Int,
): List<AlbumSummaryEntity>
/** Mirror of [getAlbumArtistPage] walking backwards; rows come out DESC. */
@Query(
"""
SELECT * FROM album_summaries
WHERE revision = :revision
AND (:includeSingles OR is_single = 0)
AND (artist_sort_key < :beforeArtistKey
OR (artist_sort_key = :beforeArtistKey AND name_sort_key < :beforeNameKey)
OR (artist_sort_key = :beforeArtistKey AND name_sort_key = :beforeNameKey
AND identity_key < :beforeId))
ORDER BY artist_sort_key DESC, name_sort_key DESC, identity_key DESC
LIMIT :limit
""",
)
suspend fun getAlbumArtistPageBefore(
revision: Long,
includeSingles: Boolean,
beforeArtistKey: String,
beforeNameKey: String,
beforeId: String,
limit: Int,
): List<AlbumSummaryEntity>
@Query(
"""
SELECT * FROM album_summaries
@@ -652,6 +743,28 @@ interface CatalogDao {
limit: Int,
): List<ArtistSummaryEntity>
/** Mirror of [getArtistNamePage] walking backwards; rows come out DESC. */
@Query(
"""
SELECT * FROM artist_summaries
WHERE revision = :revision
AND grouping_mode = :groupingMode
AND (:includeCollaborations OR is_collaboration = 0)
AND (name_sort_key < :beforeKey
OR (name_sort_key = :beforeKey AND artist_key < :beforeId))
ORDER BY name_sort_key DESC, artist_key DESC
LIMIT :limit
""",
)
suspend fun getArtistNamePageBefore(
revision: Long,
groupingMode: String,
includeCollaborations: Boolean,
beforeKey: String,
beforeId: String,
limit: Int,
): List<ArtistSummaryEntity>
@Query(
"""
SELECT * FROM artist_summaries