Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
2092431
The Library keeps its shelf across tabs and shows saved books first
StaticHumStudio Oct 3, 2026
4ed1487
A Library return re-reads the saved shelf and retries only an empty one
StaticHumStudio Oct 4, 2026
2305c53
A cold start shows the saved shelf before the server check
StaticHumStudio Oct 4, 2026
17260da
A Library return retries when no library or no saved book came back
StaticHumStudio Oct 4, 2026
d33024d
A folder scan finishes even after leaving Settings
StaticHumStudio Oct 4, 2026
121e495
First-run tab taps replace the last tab instead of piling up
StaticHumStudio Oct 4, 2026
545cfd2
Settings follows a library picked on another tab
StaticHumStudio Oct 4, 2026
992bb41
Cancelling a download clears its partial folder when nothing else cla…
StaticHumStudio Oct 4, 2026
95e0172
Home stops asking for a folder when the books are already there
StaticHumStudio Oct 4, 2026
c410721
Cancel leaves a user-picked download folder alone
StaticHumStudio Oct 4, 2026
e0e6008
Cancel cleanup and resume can no longer cross
StaticHumStudio Oct 4, 2026
fba84f2
Home's buttons bring back the tab as it was left
StaticHumStudio Oct 4, 2026
cea3b76
The background sync pulls progress every 5 minutes and the book list …
StaticHumStudio Oct 4, 2026
b9afbd5
The server can be asked what was added, and big downloads retry and s…
StaticHumStudio Oct 4, 2026
b8f9e52
The Library checks for changes in the foreground instead of downloadi…
StaticHumStudio Oct 4, 2026
6cc5333
A progress pull skips what already matches and stops fetching books o…
StaticHumStudio Oct 4, 2026
7e16565
The added-since fetch reaches back one minute, not five
StaticHumStudio Oct 4, 2026
13ec566
Finished downloads keep their track list and chapters
StaticHumStudio Oct 4, 2026
1d06534
Downloads reads its rows and covers in one query
StaticHumStudio Oct 4, 2026
92cbe9b
Failed downloads stay on the Downloads screen with Retry
StaticHumStudio Oct 4, 2026
3119965
Cancel cleanup looks up download folders in chunks
StaticHumStudio Oct 4, 2026
d48ade7
Settings keeps spaces in the username while typing
StaticHumStudio Oct 4, 2026
9ff04fd
Library and Downloads rows ask the server for smaller covers
StaticHumStudio Oct 4, 2026
406e527
Covers load in their own line so API calls never wait on them
StaticHumStudio Oct 4, 2026
2277457
A retry of the change check can no longer call a shelf with a removed…
StaticHumStudio Oct 4, 2026
ac9abab
A full refresh that fails is retried instead of being reported as rec…
StaticHumStudio Oct 4, 2026
7baddf0
A sync started for one account is never shared with or written for th…
StaticHumStudio Oct 4, 2026
f57d2f4
Books new to the shelf show the progress the server already has for them
StaticHumStudio Oct 4, 2026
f2947e8
A library list change reaches the Library selector even while a downl…
StaticHumStudio Oct 4, 2026
54b04b1
The Dossier reads only the books it has sessions for, off the main th…
StaticHumStudio Oct 4, 2026
b119ad5
Android Auto's Library and Downloaded lists read one page from the da…
StaticHumStudio Oct 4, 2026
4f633b3
Android Auto search stays in the active library and lists at most 100…
StaticHumStudio Oct 4, 2026
6186a78
The free download slot check reads only downloaded books
StaticHumStudio Oct 4, 2026
cd0b9de
Home's recently played list reads progress newest first instead of th…
StaticHumStudio Oct 4, 2026
8d98ed7
The Library shelf reads light rows instead of whole books
StaticHumStudio Oct 4, 2026
af1b8a6
A replaced shelf filter stops decoding instead of finishing in the ba…
StaticHumStudio Oct 4, 2026
f6483d0
The shelf sorts once, on keys worked out once per book
StaticHumStudio Oct 4, 2026
aab8533
Grouped views stop running a groups query nothing shows
StaticHumStudio Oct 4, 2026
3a719b7
Library search treats % and _ as plain characters
StaticHumStudio Oct 4, 2026
7c21316
A grouped view that loses its unlock shows the flat shelf, not a blan…
StaticHumStudio Oct 4, 2026
d7a19dd
Big grouped views start collapsed and build their rows off the main t…
StaticHumStudio Oct 4, 2026
583780e
A failed Library load or filter shows a banner with Retry
StaticHumStudio Oct 4, 2026
ffaf76e
Group headers tell TalkBack they are headings, open or closed
StaticHumStudio Oct 4, 2026
c6a6601
Android Auto search escapes its text with the Library's LIKE helper
StaticHumStudio Oct 4, 2026
f55e93d
The old title and author search goes, now that Android Auto pages its…
StaticHumStudio Oct 4, 2026
515b109
A book in two series groups under its first series instead of a made-…
StaticHumStudio Oct 4, 2026
111a029
One slow /ping no longer empties a streaming user's shelf
StaticHumStudio Oct 4, 2026
c2c959c
The server ping stops while the app is in the background
StaticHumStudio Oct 4, 2026
5ebde22
Playback saves its place every 10 seconds instead of twice a second
StaticHumStudio Oct 4, 2026
6fde928
A sync for the old account can no longer prune or save after the next…
StaticHumStudio Oct 4, 2026
7230609
A book cached early inside the overlap window can no longer hide a re…
StaticHumStudio Oct 4, 2026
9828c97
A refresh no longer erases a completion the progress pull imported at…
StaticHumStudio Oct 4, 2026
9c83677
Opening a book reads its own bookmarks instead of the whole profile
StaticHumStudio Oct 4, 2026
61b0470
Book detail lists chapters for books the library list synced
StaticHumStudio Oct 4, 2026
fcdb8ff
Downloads lists every book on the device, and Clear All is gone
StaticHumStudio Oct 4, 2026
fa13ce9
Retry on a failed download asks the free slot first
StaticHumStudio Oct 4, 2026
1ab2cdd
Covers follow the server address after a LAN and VPN switch
StaticHumStudio Oct 4, 2026
8e85ba0
A canceled Dossier load can no longer overwrite the newer report
StaticHumStudio Oct 4, 2026
24f5150
A delayed pause save can no longer overwrite a newer paused seek
StaticHumStudio Oct 4, 2026
efd370f
A bookmark change can no longer be undone by a fetch already in flight
StaticHumStudio Oct 4, 2026
b613405
A paused seek no longer throws away the listening time saved on pause
StaticHumStudio Oct 4, 2026
48a8c6c
A pause save at the very end keeps the book finished
StaticHumStudio Oct 4, 2026
2bb2e45
An oversized Android Auto page no longer skips books past the cap
StaticHumStudio Oct 4, 2026
1c28532
Coming back to the app re-reads the network instead of staying Offline
StaticHumStudio Oct 4, 2026
3ef24c8
A server that stops answering is re-probed after 15 seconds, not 60
StaticHumStudio Oct 4, 2026
362f189
Cold start sends one /ping, and the Library skips a check that just ran
StaticHumStudio Oct 4, 2026
d72004c
A VPN that went away no longer keeps the app online with no network
StaticHumStudio Oct 4, 2026
70c7034
A playback push that lands clears a failed probe instead of being und…
StaticHumStudio Oct 4, 2026
7c4ce91
A check skipped while offline still runs if the server came back first
StaticHumStudio Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
579 changes: 579 additions & 0 deletions app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/9.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.ninelivesaudio.app.data.local

import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.ninelivesaudio.app.data.local.entity.AudioBookEntity
import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/**
* The recently played queries now start from PlaybackProgress and walk its
* UpdatedAt index. They must return the same rows as the old library-first
* join, which is kept here as the reference.
*/
@RunWith(AndroidJUnit4::class)
class RecentlyPlayedQueryInstrumentedTest {

private lateinit var database: AppDatabase

@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).allowMainThreadQueries().build()
}

@After
fun tearDown() {
database.close()
}

@Test
fun recentQueriesReturnTheSameRowsAsTheLibraryFirstJoin() = runBlocking {
seed()
val dao = database.audioBookDao()

assertEquals(reference("lib", isLocal = null, limit = 9), dao.getRecentlyPlayedByLibrary("lib", 9).map { it.audioBook.id })
assertEquals(reference("lib", isLocal = null, limit = 9), dao.observeRecentlyPlayedByLibrary("lib", 9).first().map { it.audioBook.id })
assertEquals(reference("lib", isLocal = 0, limit = 20), dao.getRecentlyPlayedByLibraryAndSource("lib", 0, 20).map { it.audioBook.id })
assertEquals(reference(null, isLocal = null, limit = 9), dao.getRecentlyPlayed(9).map { it.audioBook.id })
assertEquals(reference(null, isLocal = null, limit = 9), dao.observeRecentlyPlayed(9).first().map { it.audioBook.id })
assertEquals(
reference("lib", isLocal = 0, limit = 10_000).size,
dao.countRecentlyPlayedByLibrary("lib", 0),
)
assertEquals(
"2026-01-30T00:00:00Z",
dao.getRecentlyPlayedByLibrary("lib", 1).single().lastPlayedAt,
)
}

@Test
fun theUpdatedAtIndexExists() {
val cursor = database.openHelper.readableDatabase.query("PRAGMA index_list(`PlaybackProgress`)")
val names = mutableListOf<String>()
cursor.use { while (it.moveToNext()) names += it.getString(it.getColumnIndexOrThrow("name")) }
assertTrue(names.toString(), "idx_playback_progress_updated" in names)
}

private suspend fun seed() {
val books = (0 until 30).map { index ->
AudioBookEntity(
id = "book-$index",
libraryId = if (index % 5 == 0) "other" else "lib",
isLocal = if (index % 7 == 0) 1 else 0,
title = "Title $index",
archivedAt = if (index % 11 == 0) 1L else null,
)
}
database.audioBookDao().upsertAll(books)
val progress = (0 until 30).filter { it % 3 != 1 }.map { index ->
PlaybackProgressEntity(
audioBookId = "book-$index",
positionSeconds = 1.0,
updatedAt = "2026-01-%02dT00:00:00Z".format(index + 1),
)
} + PlaybackProgressEntity(audioBookId = "deleted-book", updatedAt = "2027-01-01T00:00:00Z")
progress.forEach { database.playbackProgressDao().upsert(it) }
}

/** The pre-change query shape, run raw: AudioBooks first, then PlaybackProgress. */
private fun reference(libraryId: String?, isLocal: Int?, limit: Int): List<String> {
val where = buildList {
add("ab.ArchivedAt IS NULL")
if (libraryId != null) add("ab.LibraryId = '$libraryId'")
if (isLocal != null) add("ab.IsLocal = $isLocal")
}.joinToString(" AND ")
val sql = "SELECT ab.Id FROM AudioBooks ab INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId " +
"WHERE $where ORDER BY pp.UpdatedAt DESC LIMIT $limit"
val ids = mutableListOf<String>()
database.openHelper.readableDatabase.query(sql).use { while (it.moveToNext()) ids += it.getString(0) }
return ids
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.ninelivesaudio.app.data.local

import androidx.room.Room
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.ninelivesaudio.app.data.local.converter.toDomain
import com.ninelivesaudio.app.data.local.entity.AudioBookEntity
import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity
import com.ninelivesaudio.app.data.local.entity.toShelfBook
import com.ninelivesaudio.app.data.repository.buildLibrarySql
import com.ninelivesaudio.app.data.repository.buildLibrarySqlArgs
import com.ninelivesaudio.app.domain.model.AudioBook
import com.ninelivesaudio.app.domain.util.toEpochMillis
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/**
* The light shelf query returns the same books, with the same shelf fields,
* as the former `ab.*` read, on a real Room database.
*/
@RunWith(AndroidJUnit4::class)
class ShelfProjectionInstrumentedTest {

private lateinit var database: AppDatabase

@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).allowMainThreadQueries().build()
}

@After
fun tearDown() {
database.close()
}

@Test
fun shelfRowsMatchTheFullRowsMinusTheHeavyFields() = runBlocking {
val dao = database.audioBookDao()
dao.upsertAll(
listOf(
book("b", "Beta", chaptersJson = """[{"Id":1,"Start":0.0,"End":60.0,"Title":"One"}]"""),
book("a", "alpha", genresJson = """["Horror"]"""),
book("c", "Gamma", archivedAt = 1L),
book("d", "Delta", libraryId = "other"),
)
)
database.playbackProgressDao().upsert(
PlaybackProgressEntity(audioBookId = "b", positionSeconds = 30.0, updatedAt = "2026-09-20T08:30:00Z")
)

val former: Map<String, AudioBook> = dao.getByLibraryWithLastPlayed(LIBRARY)
.filter { it.audioBook.archivedAt == null }
.associate { result ->
result.audioBook.id to result.audioBook.toDomain().copy(
lastPlayedAt = result.lastPlayedAt?.toEpochMillis(),
description = null,
audioFiles = emptyList(),
tags = emptyList(),
)
}
val shelf = dao.getFilteredBooks(
SimpleSQLiteQuery(
buildLibrarySql(tab = 0, hideFinished = false, downloadedOnly = false, hasSearch = false),
arrayOf(LIBRARY),
)
).map { it.toShelfBook() }

assertEquals(former, shelf.associateBy { it.id })
assertEquals(setOf("a", "b"), shelf.map { it.id }.toSet())
assertEquals(1, shelf.single { it.id == "b" }.chapters.size)
}

@Test
fun percentAndUnderscoreMatchOnlyThemselves() = runBlocking {
val dao = database.audioBookDao()
dao.upsertAll(
listOf(
book("pct", "100% Pure"),
book("num", "100 Days"),
book("under", "a_b"),
book("any", "axb"),
)
)

suspend fun shelfSearch(query: String) = dao.getFilteredBooks(
SimpleSQLiteQuery(
buildLibrarySql(tab = 0, hideFinished = false, downloadedOnly = false, hasSearch = true),
buildLibrarySqlArgs(LIBRARY, query),
)
).map { it.id }.toSet()

assertEquals(setOf("pct"), shelfSearch("%"))
assertEquals(setOf("under"), shelfSearch("_"))
assertEquals(setOf("pct", "num"), shelfSearch("100"))
}

private fun book(
id: String,
title: String,
libraryId: String = LIBRARY,
archivedAt: Long? = null,
genresJson: String = "[]",
chaptersJson: String = "[]",
) = AudioBookEntity(
id = id,
libraryId = libraryId,
title = title,
author = "Author $id",
description = "Long description for $id",
durationSeconds = 120.0,
addedAt = "2026-09-01T12:00:00Z",
audioFilesJson = """[{"Id":"f-$id","Ino":"1","Index":1,"Duration":120.0,"Filename":"$id.mp3"}]""",
progress = 0.25,
archivedAt = archivedAt,
seriesName = "Series",
seriesSequence = "1",
genresJson = genresJson,
tagsJson = """["tag"]""",
chaptersJson = chaptersJson,
)

private companion object {
const val LIBRARY = "lib"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package com.ninelivesaudio.app.service

import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.ninelivesaudio.app.data.local.AppDatabase
import com.ninelivesaudio.app.data.local.converter.toDomain
import com.ninelivesaudio.app.data.local.entity.AudioBookEntity
import com.ninelivesaudio.app.domain.model.AppMode
import com.ninelivesaudio.app.domain.model.AppSettings
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/** Android Auto's SQL pages return what the old load, sort and drop/take did. */
@RunWith(AndroidJUnit4::class)
class AutoBrowseQueryInstrumentedTest {

private lateinit var database: AppDatabase

private val settings = AppSettings(appMode = AppMode.AUDIOBOOKSHELF, selectedLibraryId = LIBRARY)

@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).allowMainThreadQueries().build()
}

@After
fun tearDown() {
database.close()
}

@Test
fun libraryPagesMatchTheFormerInMemoryPaging() = runBlocking {
seed()
val dao = database.audioBookDao()
val former = browseBooksForAuto(dao.getAll().map { it.toDomain() }, settings)
.sortedBy { it.title.lowercase() }

for (page in 0..3) {
val expected = former.drop(page * PAGE).take(PAGE).map { it.id }
val actual = dao.getAutoBrowsePage(LIBRARY, isLocal = 0, limit = PAGE, offset = page * PAGE).map { it.id }
assertEquals("page $page", expected, actual)
}
}

@Test
fun downloadedPagesMatchTheFormerInMemoryPaging() = runBlocking {
seed()
val dao = database.audioBookDao()
val former = browseBooksForAuto(dao.getAll().map { it.toDomain() }, settings)
.filter { it.isDownloaded }
.sortedBy { it.title.lowercase() }

for (page in 0..3) {
val expected = former.drop(page * PAGE).take(PAGE).map { it.id }
val actual = dao.getAutoDownloadedPage(LIBRARY, isLocal = 0, limit = PAGE, offset = page * PAGE).map { it.id }
assertEquals("page $page", expected, actual)
}
}

@Test
fun equalTitlesNeverRepeatOrSkipAcrossPages() = runBlocking {
val dao = database.audioBookDao()
dao.upsertAll((0 until 9).map { book(id = "same-$it", title = "Same Title") })

val paged = (0..2).flatMap { page ->
dao.getAutoBrowsePage(LIBRARY, isLocal = 0, limit = 3, offset = page * 3).map { it.id }
}

assertEquals((0 until 9).map { "same-$it" }, paged)
}

@Test
fun searchStaysInScopeMatchesWildcardsLiterallyAndCounts() = runBlocking {
val dao = database.audioBookDao()
dao.upsertAll(
listOf(
book(id = "pct", title = "100% Wolf"),
book(id = "plain", title = "100 Wolves"),
book(id = "under", title = "a_b"),
book(id = "underless", title = "axb"),
book(id = "by-author", title = "Other").copy(author = "Wolfgang"),
book(id = "archived", title = "Wolf Archived", archivedAt = 1L),
book(id = "other-library", title = "Wolf Elsewhere", libraryId = "other"),
book(id = "local", title = "Wolf Local", isLocal = 1),
),
)

fun ids(rows: List<com.ninelivesaudio.app.data.local.entity.AutoBrowseRow>) = rows.map { it.id }

assertEquals(listOf("pct"), ids(dao.searchAutoPage(LIBRARY, 0, autoSearchPattern("100%")!!, 100, 0)))
assertEquals(listOf("under"), ids(dao.searchAutoPage(LIBRARY, 0, autoSearchPattern("a_b")!!, 100, 0)))
assertEquals(
listOf("pct", "by-author"),
ids(dao.searchAutoPage(LIBRARY, 0, autoSearchPattern("wolf")!!, 100, 0)),
)
assertEquals(2, dao.countAutoSearch(LIBRARY, 0, autoSearchPattern("wolf")!!, 100))
assertEquals(1, dao.countAutoSearch(LIBRARY, 0, autoSearchPattern("wolf")!!, 1))
}

private suspend fun seed() {
// Mixed case and distinct titles, so NOCASE and lowercase() agree on order.
val titles = (0 until 40).map { index ->
val word = "Title %02d".format((index * 17) % 40)
if (index % 2 == 0) word.uppercase() else word.lowercase()
}
val books = titles.mapIndexed { index, title ->
book(id = "book-$index", title = title, isDownloaded = if (index % 3 == 0) 1 else 0)
} + listOf(
book(id = "archived", title = "A Archived", archivedAt = 1L, isDownloaded = 1),
book(id = "other-library", title = "A Other", libraryId = "other", isDownloaded = 1),
book(id = "local", title = "A Local", isLocal = 1, isDownloaded = 1),
)
database.audioBookDao().upsertAll(books)
}

private fun book(
id: String,
title: String,
libraryId: String = LIBRARY,
isLocal: Int = 0,
isDownloaded: Int = 0,
archivedAt: Long? = null,
) = AudioBookEntity(
id = id,
libraryId = libraryId,
isLocal = isLocal,
title = title,
author = "Author",
description = "A long description that the light rows never read.",
isDownloaded = isDownloaded,
archivedAt = archivedAt,
genresJson = "[\"Mystery\"]",
)

private companion object {
const val LIBRARY = "lib"
const val PAGE = 7
}
}
Loading
Loading